Editor embedding
Saving and callbacks
How the gate stores versions, the saved and closed callback payloads, HMAC verification in Node.js, PHP, Python and C#, retries and key rotation.
On this page
A document is saved three ways: the editor server autosaves, the user saves (or the page calls save()), and a final save when the last user leaves. All three reach the gate, which stores a new version and tells you through a callback.
Rule: saves are never blocked#
- The gate accepts saves from a session that was ever in
edit, EVEN if the session expired or closed, because the editor server saves one last time after the user leaves. - Saving ignores the connection cap and the license.
- The gate writes the file (temp file then rename), increments
version, computessha256, answers the editor server AT ONCE, and only then sends the callback in the background. A slow or failing callback never affects the save. - The gate keeps the 5 latest versions plus the original
v0, forO3O_EMBED_RETAIN_HOURS(24 hours by default) after the document closes. - An empty body (0 bytes) is refused and creates no version. Above
O3O_EMBED_MAX_FILE_MB(100 MB by default):413.
Callback payload#
POST to editor.callbackUrl, Content-Type: application/json; charset=utf-8, User-Agent: O3O-Gate/1.0. The gate does not follow redirects, and this URL is subject to the same SSRF rules as document.url.
POST /o3o/callback HTTP/1.1
Host: app.example.com
Content-Type: application/json; charset=utf-8
User-Agent: O3O-Gate/1.0
X-O3O-Event: document.saved
X-O3O-Delivery: 6f1c2b7e-2d0a-4c61-9a57-0f3b1c8d9e21
X-O3O-Timestamp: 1790086400
X-O3O-Signature: sha256=<hex HMAC_SHA256(O3O_EMBED_CALLBACK_SECRET, "1790086400." + raw body)>
{"status": "saved", "key": "hopdong-42-v7", "version": 3, ...}{
"status": "saved",
"key": "hopdong-42-v7",
"version": 3,
"url": "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=3&exp=1790086400&sig=5f1c…",
"fileType": "docx",
"title": "Hợp đồng mẫu.docx",
"size": 48890,
"sha256": "b1946ac92492d2347c6235b4d2611184…",
"users": [
{
"id": "u-1001",
"name": "Nguyễn Văn A"
},
{
"id": "u-1002",
"name": "Trần Thị B"
}
],
"savedBy": {
"id": "u-1001",
"name": "Nguyễn Văn A"
},
"autosave": false,
"final": false,
"modifiedByUser": true,
"timestamp": "2026-09-21T10:05:00Z"
}Callback fields
status"saved" | "closed"requiredsaved: a new version exists; the document may still be open.closed: everyone has left (no view for 30 consecutive seconds).keystringrequiredExactly the config'sdocument.key.versionintegerrequiredIncreases from 1. Forclosed, the last version, or0if never saved.urlstring | nullrequiredSigned download URL for that version, valid forO3O_EMBED_RETAIN_HOURShours.nullforclosedwithversion = 0.fileTypestringrequireddocument.fileTypetitlestringrequiredNormalised display name.sizeintegerrequiredSize in bytes.sha256stringrequiredHex SHA-256; compare it before overwriting your original.usersarrayrequiredUsers who have the document open at send time, as{id, name}. Empty forclosed.savedByobject | nullrequiredOwner of the session that caused the save.nullforclosed.autosavebooleanrequiredThe server saved on its own.finalbooleanrequiredtrue= the save made as the last user left. Aclosedcallback follows afinalone.modifiedByUserbooleanrequiredThe user changed something since the previous save.timestampstringrequiredSend time, ISO 8601 UTC.
{
"status": "closed",
"key": "hopdong-42-v7",
"version": 4,
"url": "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=4&exp=1790090000&sig=9a0e…",
"fileType": "docx",
"title": "Hợp đồng mẫu.docx",
"size": 49102,
"sha256": "7d865e959b2466918c9863afca942d0f…",
"users": [],
"savedBy": null,
"autosave": false,
"final": false,
"modifiedByUser": false,
"timestamp": "2026-09-21T10:31:00Z"
}Header#
| Header | Content |
|---|---|
X-O3O-Event | document.saved or document.closed |
X-O3O-Delivery | UUID of the delivery; unchanged across retries. Use it to de-duplicate. |
X-O3O-Timestamp | Unix seconds at signing time. |
X-O3O-Signature | sha256= + hex of HMAC_SHA256(O3O_EMBED_CALLBACK_SECRET, X-O3O-Timestamp + "." + raw_body). Absent when the server has no secret. |
Verify the HMAC signature#
- Read the RAW body as bytes, before JSON parsing. Parsing and re-serialising changes the bytes and breaks the signature.
- Compute
"sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + body)). - Compare with a constant-time comparison.
- Reject when
X-O3O-Timestampis more than 300 seconds away from your clock.
// npm install express
const crypto = require("crypto");
const express = require("express");
// rawBody is the RAW request body Buffer, before any JSON parsing
function verifyO3OSignature(secret, timestamp, rawBody, signature, toleranceSeconds = 300) {
const ts = Number(timestamp);
if (!signature || !Number.isInteger(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(String(signature));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as a Buffer; do not let express.json() run before this route
app.post("/o3o/callback", express.raw({ type: "*/*", limit: "1mb" }), (req, res) => {
const ok = verifyO3OSignature(process.env.O3O_EMBED_CALLBACK_SECRET, req.get("X-O3O-Timestamp"), req.body, req.get("X-O3O-Signature"));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204); // answer within 10 seconds, do heavy work afterwards
console.log(req.get("X-O3O-Delivery"), event.status, event.key, event.version);
});
app.listen(3000);
module.exports = { verifyO3OSignature };<?php
// Plain PHP; in Laravel use $request->getContent() and $request->header(...)
function verify_o3o_signature(string $secret, string $timestamp, string $rawBody, string $signature, int $tolerance = 300): bool
{
if ($signature === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$raw = file_get_contents('php://input'); // raw body
$ok = verify_o3o_signature(
getenv('O3O_EMBED_CALLBACK_SECRET') ?: '',
$_SERVER['HTTP_X_O3O_TIMESTAMP'] ?? '',
$raw,
$_SERVER['HTTP_X_O3O_SIGNATURE'] ?? ''
);
if (!$ok) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
error_log(($_SERVER['HTTP_X_O3O_DELIVERY'] ?? '') . ' ' . $event['status'] . ' v' . $event['version']);
http_response_code(204);# pip install flask
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
def verify_o3o_signature(secret: str, timestamp: str, raw_body: bytes, signature: str, tolerance: int = 300) -> bool:
if not signature or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
app = Flask(__name__)
@app.post("/o3o/callback")
def o3o_callback():
raw = request.get_data() # raw body, before JSON parsing
ok = verify_o3o_signature(
os.environ.get("O3O_EMBED_CALLBACK_SECRET", ""),
request.headers.get("X-O3O-Timestamp", ""),
raw,
request.headers.get("X-O3O-Signature", ""),
)
if not ok:
abort(401)
event = json.loads(raw)
print(request.headers.get("X-O3O-Delivery"), event["status"], event["key"], event["version"])
return "", 204
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3000)// Program.cs — ASP.NET Core (.NET 8)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var app = WebApplication.CreateBuilder(args).Build();
app.MapPost("/o3o/callback", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // raw body
var raw = buffer.ToArray();
var ok = VerifyO3OSignature(
Environment.GetEnvironmentVariable("O3O_EMBED_CALLBACK_SECRET") ?? "",
request.Headers["X-O3O-Timestamp"].ToString(),
raw,
request.Headers["X-O3O-Signature"].ToString());
if (!ok) return Results.Unauthorized();
using var ev = JsonDocument.Parse(raw);
Console.WriteLine($"{request.Headers["X-O3O-Delivery"]} {ev.RootElement.GetProperty("status").GetString()} v{ev.RootElement.GetProperty("version").GetInt32()}");
return Results.NoContent();
});
app.Run("http://0.0.0.0:3000");
static bool VerifyO3OSignature(string secret, string timestamp, byte[] rawBody, string signature, int toleranceSeconds = 300)
{
if (!long.TryParse(timestamp, out var ts)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
hmac.TransformBlock(prefix, 0, prefix.Length, null, 0);
hmac.TransformFinalBlock(rawBody, 0, rawBody.Length);
var expected = "sha256=" + Convert.ToHexString(hmac.Hash!).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature));
}Response and retries#
- Answer with any
2xxwithin 10 seconds; the response body is ignored. Do heavy work (download, storage writes) after answering. - Any other code, or more than 10 seconds, is a failure. The gate retries after 10 seconds and then 60 seconds (3 attempts in total), then logs at ERROR level and gives up.
- A failed callback does not affect editors; the file stays on the gate for the retention time and
onSavedstill fires. - Callbacks may arrive twice (retries) or out of order: de-duplicate on
X-O3O-Deliveryand only write whenversionis newer than what you have.
Download the saved copy#
# The callback URL is pre-signed: download it directly, no key needed
curl -f -o hop-dong-v3.docx "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=3&exp=1790086400&sig=<sig>"
# Compare with the callback sha256 field before overwriting your original
sha256sum hop-dong-v3.docxClosing and key rotation#
A closed callback means nobody has the document open. From then on, the next open MUST use a new document.key, so the gate downloads the file from document.url again (which is now the copy you just stored). A common pattern: keep a counter per file, key = "contract-r" + counter, and increment it on closed. The integration examples do exactly that.