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#
| Operation | Request | Required | Notes |
|---|---|---|---|
| CheckFileInfo | GET /wopi/files/{id}?access_token=… | Yes | Returns JSON metadata and the permissions of the current user. |
| GetFile | GET /wopi/files/{id}/contents?access_token=… | Yes | Returns the raw file content. |
| PutFile | POST /wopi/files/{id}/contents?access_token=…, X-WOPI-Override: PUT | Yes, if editing is allowed | Body is the whole new file. Answer 200 with LastModifiedTime. |
| Lock / Unlock / RefreshLock / GetLock | POST /wopi/files/{id}, X-WOPI-Override: LOCK… | No | Declare SupportsLocks: false and the editor server does not call them. |
| PutRelativeFile, RenameFile | POST /wopi/files/{id}, X-WOPI-Override: PUT_RELATIVE… | No | Declare 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
BaseFileNamestringrequiredFile name with extension. The extension decides which editor is used.SizeintegerrequiredSize in bytes.OwnerIdstringrequiredOwner id of the file in your system.UserIdstringrequiredId of the user opening the file. Two sessions on the same file with differentUserIdsee each other as co-editors.VersionstringrequiredA string that changes whenever the file content changes.UserFriendlyNamestringoptionalDisplay name of the user.UserCanWritebooleanoptionalDefault:falsetrueis required to edit and save.falsesessions are not counted as connections.UserCanNotWriteRelativebooleanoptionalDefault:falsetrueif you do not implement PutRelativeFile.SupportsLocksbooleanoptionalDefault:falsetrueonly when you implement all four lock operations.SupportsUpdatebooleanoptionalDefault:falsetruewhen you implement PutFile.LastModifiedTimestringoptionalLast modification time, ISO 8601 UTC, e.g.2026-09-21T10:00:00.0000000Z.PostMessageOriginstringoptionalOrigin of the page hosting the iframe, so the editor frame postspostMessageevents to the right page.HidePrintOption, DisablePrintbooleanoptionalDefault:falseHide and block printing.HideExportOption, DisableExportbooleanoptionalDefault:falseHide and block download in other formats.HideSaveOptionbooleanoptionalDefault:falseHide the save command (autosave still runs).DisableCopybooleanoptionalDefault:falseBlock copying content out of the frame.
{
"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.
| Header | Value | Meaning |
|---|---|---|
X-WOPI-Override | PUT | Marks the request as PutFile. |
X-COOL-WOPI-IsModifiedByUser | true / false | The user changed something since the previous save. |
X-COOL-WOPI-IsAutosave | true / false | The server saved on its own. |
X-COOL-WOPI-IsExitSave | true / false | Saved as the last user left the document. |
{
"LastModifiedTime": "2026-09-21T10:05:00.0000000Z"
}Open the editor from your page#
- Read the
urlsrcfor the file extension from discovery. - Build
action = urlsrc + "WOPISrc=" + encodeURIComponent(file WOPI URL) + "&lang=vi". - Submit a form POST into a named iframe with two hidden fields,
access_tokenandaccess_token_ttl(Unix milliseconds when the token expires). Never put the token in the URL.
Minimal code#
"""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, "&")}">
<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.
# 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"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# 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" # PutFileConnection 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.
Save as (PutRelativeFile) and a proof key so hosts can verify the editor server's X-WOPI-Proof signature.