Skip to content

Storage integration

Build your own WOPI host Beta

CheckFileInfo, GetFile, PutFile, locks, required fields, opening the editor with a form POST, and a minimal WOPI host in Python and Node.js.

This page describes a beta feature: it works but may still change.

On this page

When your application has its own file store and permission model, you can act as the WOPI host yourself: the O3O editor server calls your endpoints directly to read and write files. This page describes the minimum you must implement, with runnable code in Python (standard library) and Node.js (Express). Both examples were run against the DEV editor image: open a file, then save it back through PutFile.

Operations to implement#

OperationRequestRequiredNotes
CheckFileInfoGET /wopi/files/{id}?access_token=…YesReturns JSON metadata and the permissions of the current user.
GetFileGET /wopi/files/{id}/contents?access_token=…YesReturns the raw file content.
PutFilePOST /wopi/files/{id}/contents?access_token=…, X-WOPI-Override: PUTYes, if editing is allowedBody is the whole new file. Answer 200 with LastModifiedTime.
Lock / Unlock / RefreshLock / GetLockPOST /wopi/files/{id}, X-WOPI-Override: LOCKNoDeclare SupportsLocks: false and the editor server does not call them.
PutRelativeFile, RenameFilePOST /wopi/files/{id}, X-WOPI-Override: PUT_RELATIVENoDeclare UserCanNotWriteRelative: true to hide “save as”.

The /wopi/files/ prefix is a convention; choose any prefix as long as WOPISrc points to it. {id} is your file id; the editor server sends it back exactly as it appears in WOPISrc.

CheckFileInfo#

CheckFileInfo fields

  • BaseFileNamestringrequired
    File name with extension. The extension decides which editor is used.
  • Sizeintegerrequired
    Size in bytes.
  • OwnerIdstringrequired
    Owner id of the file in your system.
  • UserIdstringrequired
    Id of the user opening the file. Two sessions on the same file with different UserId see each other as co-editors.
  • Versionstringrequired
    A string that changes whenever the file content changes.
  • UserFriendlyNamestringoptional
    Display name of the user.
  • UserCanWritebooleanoptionalDefault: false
    true is required to edit and save. false sessions are not counted as connections.
  • UserCanNotWriteRelativebooleanoptionalDefault: false
    true if you do not implement PutRelativeFile.
  • SupportsLocksbooleanoptionalDefault: false
    true only when you implement all four lock operations.
  • SupportsUpdatebooleanoptionalDefault: false
    true when you implement PutFile.
  • LastModifiedTimestringoptional
    Last modification time, ISO 8601 UTC, e.g. 2026-09-21T10:00:00.0000000Z.
  • PostMessageOriginstringoptional
    Origin of the page hosting the iframe, so the editor frame posts postMessage events to the right page.
  • HidePrintOption, DisablePrintbooleanoptionalDefault: false
    Hide and block printing.
  • HideExportOption, DisableExportbooleanoptionalDefault: false
    Hide and block download in other formats.
  • HideSaveOptionbooleanoptionalDefault: false
    Hide the save command (autosave still runs).
  • DisableCopybooleanoptionalDefault: false
    Block copying content out of the frame.
200Example CheckFileInfo response
{
  "BaseFileName": "Hợp đồng mẫu.docx",
  "Size": 48213,
  "Version": "3",
  "OwnerId": "owner-7",
  "UserId": "u-1001",
  "UserFriendlyName": "Nguyễn Văn A",
  "UserCanWrite": true,
  "UserCanNotWriteRelative": true,
  "SupportsLocks": false,
  "SupportsUpdate": true,
  "LastModifiedTime": "2026-09-21T10:00:00.0000000Z",
  "PostMessageOrigin": "https://app.example.com"
}

PutFile#

The editor server sends the whole new file in the request body. Write to a temporary file and rename it, so you never leave a half-written file. The three headers below tell you why this save happened; you may use them to decide whether to create a new history entry.

HeaderValueMeaning
X-WOPI-OverridePUTMarks the request as PutFile.
X-COOL-WOPI-IsModifiedByUsertrue / falseThe user changed something since the previous save.
X-COOL-WOPI-IsAutosavetrue / falseThe server saved on its own.
X-COOL-WOPI-IsExitSavetrue / falseSaved as the last user left the document.
200PutFile response
{
  "LastModifiedTime": "2026-09-21T10:05:00.0000000Z"
}

Open the editor from your page#

  1. Read the urlsrc for the file extension from discovery.
  2. Build action = urlsrc + "WOPISrc=" + encodeURIComponent(file WOPI URL) + "&lang=vi".
  3. Submit a form POST into a named iframe with two hidden fields, access_token and access_token_ttl (Unix milliseconds when the token expires). Never put the token in the URL.

Minimal code#

wopi_host.py · wopi-host.js
"""Minimal WOPI host to try O3O Office Online on a DEV machine.

python wopi_host.py   →   http://localhost:5000/open/hop-dong.docx
Put the files to open in ./files. Standard library only, Python 3.9 or newer.
"""
import html
import json
import os
import secrets
import time
import urllib.request
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, unquote, urlparse

PORT = int(os.environ.get("PORT", "5000"))
O3O_URL = os.environ.get("O3O_URL", "http://localhost:8080")                    # O3O as the browser sees it
WOPI_BASE = os.environ.get("WOPI_BASE", f"http://host.docker.internal:{PORT}")  # this host as o3o-online sees it
FILES = os.environ.get("FILES_DIR", "files")
TOKENS = {}  # access_token -> {"file", "user", "write", "exp_ms"}


def urlsrc_for(ext):
    xml = urllib.request.urlopen(O3O_URL + "/hosting/discovery", timeout=10).read()
    for action in ET.fromstring(xml).iter("action"):
        if action.get("ext") == ext and action.get("name") == "edit":
            return action.get("urlsrc")
    raise LookupError("no edit action for ." + ext)


def iso_time(ts):
    return time.strftime("%Y-%m-%dT%H:%M:%S.0000000Z", time.gmtime(ts))


class WopiHandler(BaseHTTPRequestHandler):
    def reply(self, code, body=b"", ctype="application/json"):
        if isinstance(body, dict):
            body = json.dumps(body).encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def grant(self):
        """Return (file name, grant, path tail) when access_token is valid for this very file."""
        url = urlparse(self.path)
        parts = url.path.strip("/").split("/")
        if len(parts) not in (3, 4) or parts[:2] != ["wopi", "files"]:
            return None, None, None
        name = unquote(parts[2])
        g = TOKENS.get(parse_qs(url.query).get("access_token", [""])[0])
        if not g or g["file"] != name or g["exp_ms"] < time.time() * 1000:
            return name, None, None
        return name, g, parts[3:]

    def do_GET(self):
        if self.path.startswith("/open/"):
            return self.open_editor(os.path.basename(unquote(urlparse(self.path).path[6:])))
        name, g, tail = self.grant()
        if g is None:
            return self.reply(401 if name else 404)
        path = os.path.join(FILES, name)
        if tail == ["contents"]:                                   # GetFile
            with open(path, "rb") as f:
                return self.reply(200, f.read(), "application/octet-stream")
        st = os.stat(path)                                         # CheckFileInfo
        self.reply(200, {
            "BaseFileName": name,
            "Size": st.st_size,
            "Version": str(st.st_mtime_ns),
            "OwnerId": "demo-owner",
            "UserId": g["user"],
            "UserFriendlyName": g["user"],
            "UserCanWrite": g["write"],
            "UserCanNotWriteRelative": True,
            "SupportsLocks": False,
            "SupportsUpdate": True,
            "LastModifiedTime": iso_time(st.st_mtime),
            "PostMessageOrigin": f"http://localhost:{PORT}",
        })

    def do_POST(self):
        name, g, tail = self.grant()
        if g is None:
            return self.reply(401 if name else 404)
        if tail != ["contents"] or self.headers.get("X-WOPI-Override") != "PUT":
            return self.reply(501)                                 # LOCK, PUT_RELATIVE... not supported
        if not g["write"]:
            return self.reply(401)
        data = self.rfile.read(int(self.headers.get("Content-Length") or 0))
        path = os.path.join(FILES, name)                           # PutFile
        with open(path + ".tmp", "wb") as f:
            f.write(data)
        os.replace(path + ".tmp", path)
        self.reply(200, {"LastModifiedTime": iso_time(os.stat(path).st_mtime)})

    def open_editor(self, name):
        if not os.path.isfile(os.path.join(FILES, name)):
            return self.reply(404)
        token = secrets.token_urlsafe(32)
        exp_ms = int((time.time() + 8 * 3600) * 1000)
        TOKENS[token] = {"file": name, "user": "u-1001", "write": True, "exp_ms": exp_ms}
        wopi_src = f"{WOPI_BASE}/wopi/files/{quote(name)}"
        action = urlsrc_for(name.rsplit(".", 1)[-1].lower()) + "WOPISrc=" + quote(wopi_src, safe="") + "&lang=vi"
        page = f"""<!doctype html><html lang="vi"><head><meta charset="utf-8"><title>{html.escape(name)}</title>
<style>html,body{{margin:0;height:100%}}iframe{{border:0;width:100%;height:100%}}</style></head><body>
<form id="f" method="post" target="o3o" action="{html.escape(action)}">
<input type="hidden" name="access_token" value="{token}">
<input type="hidden" name="access_token_ttl" value="{exp_ms}"></form>
<iframe name="o3o" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
<script>document.getElementById("f").submit();</script></body></html>"""
        self.reply(200, page.encode(), "text/html; charset=utf-8")


if __name__ == "__main__":
    print(f"http://localhost:{PORT}/open/<file>   (./{FILES})")
    ThreadingHTTPServer(("0.0.0.0", PORT), WopiHandler).serve_forever()
// wopi-host.js — minimal WOPI host for a DEV machine
// npm install express   ·   node wopi-host.js   →   http://localhost:5000/open/hop-dong.docx
const express = require("express");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

const PORT = Number(process.env.PORT || 5000);
const O3O_URL = process.env.O3O_URL || "http://localhost:8080";                    // O3O as the browser sees it
const WOPI_BASE = process.env.WOPI_BASE || `http://host.docker.internal:${PORT}`;  // this host as o3o-online sees it
const FILES = path.resolve(process.env.FILES_DIR || "files");
const tokens = new Map(); // access_token -> { file, user, write, expMs }

const isoTime = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, ".0000000Z");

async function urlsrcFor(ext) {
  const xml = await (await fetch(`${O3O_URL}/hosting/discovery`)).text();
  const m = xml.match(new RegExp(`<action[^>]*ext="${ext}"[^>]*name="edit"[^>]*urlsrc="([^"]+)"`));
  if (!m) throw new Error(`no edit action for .${ext}`);
  return m[1];
}

function grantFor(req) {
  const g = tokens.get(String(req.query.access_token || ""));
  return g && g.file === req.params.id && g.expMs > Date.now() ? g : null;
}

const app = express();

app.get("/wopi/files/:id", (req, res) => {                                  // CheckFileInfo
  const g = grantFor(req);
  if (!g) return res.sendStatus(401);
  const st = fs.statSync(path.join(FILES, g.file));
  res.json({
    BaseFileName: g.file,
    Size: st.size,
    Version: String(Math.floor(st.mtimeMs)),
    OwnerId: "demo-owner",
    UserId: g.user,
    UserFriendlyName: g.user,
    UserCanWrite: g.write,
    UserCanNotWriteRelative: true,
    SupportsLocks: false,
    SupportsUpdate: true,
    LastModifiedTime: isoTime(st.mtimeMs),
    PostMessageOrigin: `http://localhost:${PORT}`
  });
});

app.get("/wopi/files/:id/contents", (req, res) => {                         // GetFile
  const g = grantFor(req);
  if (!g) return res.sendStatus(401);
  res.type("application/octet-stream").sendFile(path.join(FILES, g.file));
});

app.post("/wopi/files/:id/contents", express.raw({ type: "*/*", limit: "100mb" }), (req, res) => {  // PutFile
  const g = grantFor(req);
  if (!g || !g.write) return res.sendStatus(401);
  if (req.get("X-WOPI-Override") !== "PUT") return res.sendStatus(501);
  const file = path.join(FILES, g.file);
  fs.writeFileSync(file + ".tmp", req.body);
  fs.renameSync(file + ".tmp", file);
  res.json({ LastModifiedTime: isoTime(fs.statSync(file).mtimeMs) });
});

app.post("/wopi/files/:id", (req, res) => res.sendStatus(501));             // LOCK, PUT_RELATIVE... not supported

app.get("/open/:name", async (req, res) => {
  const name = path.basename(req.params.name);
  if (!fs.existsSync(path.join(FILES, name))) return res.sendStatus(404);
  const token = crypto.randomBytes(32).toString("base64url");
  const expMs = Date.now() + 8 * 3600 * 1000;
  tokens.set(token, { file: name, user: "u-1001", write: true, expMs });
  const wopiSrc = `${WOPI_BASE}/wopi/files/${encodeURIComponent(name)}`;
  const action = (await urlsrcFor(path.extname(name).slice(1).toLowerCase()))
    + "WOPISrc=" + encodeURIComponent(wopiSrc) + "&lang=vi";
  res.type("html").send(`<!doctype html><html lang="vi"><head><meta charset="utf-8"><title>${name}</title>
<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%}</style></head><body>
<form id="f" method="post" target="o3o" action="${action.replace(/&/g, "&amp;")}">
<input type="hidden" name="access_token" value="${token}">
<input type="hidden" name="access_token_ttl" value="${expMs}"></form>
<iframe name="o3o" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
<script>document.getElementById("f").submit();</script></body></html>`);
});

app.listen(PORT, "0.0.0.0", () => console.log(`http://localhost:${PORT}/open/<file>  (${FILES})`));

Let the editor server reach your host#

WOPISrc must be reachable from the o3o-online container and must belong to an alias group. On Docker Desktop your machine is host.docker.internal. The standard compose file only maps aliasgroup1 (gate) and aliasgroup2 (Nextcloud), so add group 3 with an extra compose file. Your host must listen on 0.0.0.0, not only 127.0.0.1.

YAMLcompose.wopi.yml
# online/docker/compose.wopi.yml — declare your WOPI host to the editor server
# aliasgroup1 is always the gate, aliasgroup2 is Nextcloud; use group 3 for your host.
services:
  o3o-online:
    environment:
      aliasgroup3: "http://host.docker.internal:5000"
  o3o-gate:
    environment:
      # optional: let the gate ask CheckFileInfo once the cap is reached
      O3O_WOPI_ALLOWED_HOSTS: "http://host.docker.internal:5000"
BashTry it
cd online
docker compose --env-file .env -f docker/compose.dev.yml -f docker/compose.wopi.yml up -d
mkdir -p files && cp ~/Documents/hop-dong.docx files/
python wopi_host.py        # or: node wopi-host.js
# Open in a browser: http://localhost:5000/open/hop-dong.docx
BashCall the three operations with curl
# Call your WOPI host directly with a freshly issued access_token (view the source of the /open/... page)
TOKEN=<access_token>
curl -s "http://localhost:5000/wopi/files/hop-dong.docx?access_token=$TOKEN"                  # CheckFileInfo
curl -s -o /tmp/copy.docx "http://localhost:5000/wopi/files/hop-dong.docx/contents?access_token=$TOKEN"   # GetFile
curl -s -X POST -H "X-WOPI-Override: PUT" --data-binary @/tmp/copy.docx \
  "http://localhost:5000/wopi/files/hop-dong.docx/contents?access_token=$TOKEN"               # PutFile

Connection limits with your host#

The gate counts every editing session on the server, whatever the WOPI host. A session with UserCanWrite: true counts as one connection; read-only sessions do not count. Once the cap is reached, a new editor page is replaced by a “connection limit reached” page with an open-read-only button. If you add your host origin to O3O_WOPI_ALLOWED_HOSTS, the gate asks CheckFileInfo (up to 3 seconds) to recognise read-only sessions and let them through. Details: connection counting.

Coming soon

Save as (PutRelativeFile) and a proof key so hosts can verify the editor server's X-WOPI-Proof signature.