Skip to content

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, computes sha256, 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, for O3O_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.

HTTPHTTP request
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, ...}
JSONsaved callback body
{
  "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"required
    saved: a new version exists; the document may still be open. closed: everyone has left (no view for 30 consecutive seconds).
  • keystringrequired
    Exactly the config's document.key.
  • versionintegerrequired
    Increases from 1. For closed, the last version, or 0 if never saved.
  • urlstring | nullrequired
    Signed download URL for that version, valid for O3O_EMBED_RETAIN_HOURS hours. null for closed with version = 0.
  • fileTypestringrequired
    document.fileType
  • titlestringrequired
    Normalised display name.
  • sizeintegerrequired
    Size in bytes.
  • sha256stringrequired
    Hex SHA-256; compare it before overwriting your original.
  • usersarrayrequired
    Users who have the document open at send time, as {id, name}. Empty for closed.
  • savedByobject | nullrequired
    Owner of the session that caused the save. null for closed.
  • autosavebooleanrequired
    The server saved on its own.
  • finalbooleanrequired
    true = the save made as the last user left. A closed callback follows a final one.
  • modifiedByUserbooleanrequired
    The user changed something since the previous save.
  • timestampstringrequired
    Send time, ISO 8601 UTC.
JSONclosed callback body
{
  "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"
}
HeaderContent
X-O3O-Eventdocument.saved or document.closed
X-O3O-DeliveryUUID of the delivery; unchanged across retries. Use it to de-duplicate.
X-O3O-TimestampUnix seconds at signing time.
X-O3O-Signaturesha256= + hex of HMAC_SHA256(O3O_EMBED_CALLBACK_SECRET, X-O3O-Timestamp + "." + raw_body). Absent when the server has no secret.

Verify the HMAC signature#

  1. Read the RAW body as bytes, before JSON parsing. Parsing and re-serialising changes the bytes and breaks the signature.
  2. Compute "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + body)).
  3. Compare with a constant-time comparison.
  4. Reject when X-O3O-Timestamp is more than 300 seconds away from your clock.
Verify and receive the callback
// 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 2xx within 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 onSaved still fires.
  • Callbacks may arrive twice (retries) or out of order: de-duplicate on X-O3O-Delivery and only write when version is newer than what you have.

Download the saved copy#

BashDownload and verify
# 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.docx

Closing 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.