Skip to content

Platform

Callbacks and webhooks

Callbacks when an embedded document is saved and when a DocBuilder job finishes: format, HMAC SHA-256 signature, retry schedule and idempotent handling.

On this page

O3O calls out to your server in two places: when an embedded document has a new saved version, and when an asynchronous DocBuilder job finishes. Both share the same signing scheme, retry policy and header set.

KindSenderDestinationEventsSigning keyEdition
Embedded document savedo3o-gateeditor.callbackUrldocument.saved, document.closedO3O_EMBED_CALLBACK_SECRETBoth editions
DocBuilder job finishedo3o-docbuildercallback_url of the requestjob.done, job.failedO3O_DOCBUILDER_CALLBACK_SECRETEnterprise only, token with the callback feature

Embedded document callbacks#

The gate sends a POST with Content-Type: application/json; charset=utf-8 and User-Agent: O3O-Gate/1.0. It does not follow redirects, and callbackUrl is subject to the SSRF rules: internal addresses are rejected unless listed in O3O_FETCH_ALLOW_HOSTS. Saving never waits for the callback: the gate stores the new version, answers the editing server immediately, and only then sends the callback in the background.

JSON<code>document.saved</code> callback body
{
  "status": "saved",
  "key": "hopdong-42-v7",
  "version": 3,
  "url": "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=3&exp=1790086400&sig=5f1c0b7e9a3d4c21",
  "fileType": "docx",
  "title": "Hợp đồng mẫu.docx",
  "size": 48890,
  "sha256": "b1946ac92492d2347c6235b4d2611184b1946ac92492d2347c6235b4d2611184",
  "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 body fields

  • statusstringrequired
    saved: a new version exists and the document may still be open. closed: everyone has left (no view for 30 consecutive seconds).
  • keystringrequired
    Exactly the document.key of the config.
  • versionintegerrequired
    Increases from 1. For closed it is the last version, 0 if nothing was ever saved.
  • urlstringrequired
    Signed download URL of that version, valid for O3O_EMBED_RETAIN_HOURS hours. null for closed with version = 0.
  • fileTypestringrequired
    File extension, for example docx.
  • titlestringrequired
    Display name of the document.
  • sizeintegerrequired
    File size in bytes.
  • sha256stringrequired
    SHA-256 of the file, to check after download.
  • usersarrayrequired
    People who had the document open when it was sent. Empty for closed.
  • savedByobjectrequired
    Owner of the session that caused the save. null for closed.
  • autosavebooleanrequired
    The save was made automatically by the server.
  • finalbooleanrequired
    Save made when the last person left; a closed callback follows a final one.
  • modifiedByUserbooleanrequired
    The user changed something since the previous save.
  • timestampstringrequired
    Send time, ISO 8601 UTC.

DocBuilder job callbacks#

When a POST /v1/convert, /v1/build or /v1/template/render request has async = true and a callback_url, DocBuilder sends a JSON POST {"event": ..., "job": ...} when the job finishes (job.done) or fails (job.failed), with User-Agent: O3O-DocBuilder/1.0 and the same signed headers as above, using the O3O_DOCBUILDER_CALLBACK_SECRET key. job has the same schema as GET /v1/jobs/{id}. Downloading outputs[].url still needs the Authorization header.

JSON<code>job.done</code> callback body (illustrative values)
{
  "event": "job.done",
  "job": {
    "id": "job_4f1c2a9b0d3e5f6a7b8c9d0e",
    "kind": "convert",
    "status": "done",
    "created_at": "2026-09-21T10:00:00Z",
    "started_at": "2026-09-21T10:00:01Z",
    "finished_at": "2026-09-21T10:00:04Z",
    "expires_at": "2026-09-22T10:00:04Z",
    "outputs": [
      {
        "file_id": "file_9a8b7c6d5e4f3a2b1c0d9e8f",
        "url": "http://localhost:8080/v1/files/file_9a8b7c6d5e4f3a2b1c0d9e8f",
        "filename": "bao-cao.pdf",
        "format": "pdf",
        "content_type": "application/pdf",
        "size": 182344,
        "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
        "pages": 12,
        "expires_at": "2026-09-22T10:00:04Z"
      }
    ],
    "error": null
  }
}

The callback field of GET /v1/jobs/{id} shows the delivery state (pending, delivered, failed) and the number of attempts, handy when a callback seems to be missing.

Headers and signature#

HeaderContent
X-O3O-Eventdocument.saved, document.closed, job.done or job.failed
X-O3O-DeliveryUUID of the delivery; UNCHANGED across retries
X-O3O-TimestampUnix seconds at signing time
X-O3O-Signaturesha256= + hex of HMAC_SHA256(key, X-O3O-Timestamp + "." + raw_body). Absent when the server has no key.
  1. Read the request body RAW as bytes, before parsing JSON.
  2. Compute sha256= + hex HMAC SHA-256 of timestamp + "." + body with the shared key.
  3. Compare with X-O3O-Signature using a constant-time comparison.
  4. Reject when X-O3O-Timestamp is more than 300 seconds away from your clock.
  5. Only then parse the JSON and process it.
Verify a callback signature
// npm install express   (a .mjs file or "type": "module")
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.O3O_EMBED_CALLBACK_SECRET; // same key as O3O's O3O_EMBED_CALLBACK_SECRET
const app = express();

function verify(rawBody, timestamp, signature) {
  if (!timestamp || !signature) return false;
  // Reject when the timestamp is more than 300 seconds off
  if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return false;
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
    .update(timestamp + ".").update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// The signature covers the RAW body, before JSON parsing
app.post("/o3o/callback", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
  if (!verify(req.body, req.get("X-O3O-Timestamp"), req.get("X-O3O-Signature"))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Put the work on your own queue and answer 2xx right away; download event.url in the background
  res.sendStatus(204);
});

app.listen(3000);
# pip install fastapi uvicorn
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, Request, Response

SECRET = os.environ["O3O_EMBED_CALLBACK_SECRET"].encode()  # same key as O3O's O3O_EMBED_CALLBACK_SECRET
app = FastAPI()


def verify(raw: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not timestamp or not signature:
        return False
    try:
        # Reject when the timestamp is more than 300 seconds off
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except ValueError:
        return False
    expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/o3o/callback")
async def o3o_callback(request: Request) -> Response:
    raw = await request.body()  # The signature covers the RAW body, before JSON parsing
    if not verify(raw, request.headers.get("X-O3O-Timestamp"), request.headers.get("X-O3O-Signature")):
        return Response(status_code=401)
    event = json.loads(raw)
    # Put the work on your own queue and answer 2xx right away; download event.url in the background
    return Response(status_code=204)
<?php
$secret = getenv('O3O_EMBED_CALLBACK_SECRET'); // same key as O3O's O3O_EMBED_CALLBACK_SECRET
$raw = file_get_contents('php://input');        // The signature covers the RAW body, before JSON parsing
$ts = $_SERVER['HTTP_X_O3O_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_O3O_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
// Reject when the timestamp is more than 300 seconds off
if ($ts === '' || !ctype_digit($ts) || abs(time() - (int)$ts) > 300 || !hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
// Put the work on your own queue and answer 2xx right away; download event.url in the background
http_response_code(204);
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var app = WebApplication.CreateBuilder(args).Build();
// same key as O3O's O3O_EMBED_CALLBACK_SECRET
byte[] secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("O3O_EMBED_CALLBACK_SECRET")!);

app.MapPost("/o3o/callback", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    byte[] raw = buffer.ToArray(); // The signature covers the RAW body, before JSON parsing
    string ts = request.Headers["X-O3O-Timestamp"].ToString();
    string sig = request.Headers["X-O3O-Signature"].ToString();

    // Reject when the timestamp is more than 300 seconds off
    if (!long.TryParse(ts, out long sent) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sent) > 300)
        return Results.Unauthorized();

    using var hmac = new HMACSHA256(secret);
    byte[] mac = hmac.ComputeHash(Encoding.UTF8.GetBytes(ts + ".").Concat(raw).ToArray());
    byte[] expected = Encoding.ASCII.GetBytes("sha256=" + Convert.ToHexString(mac).ToLowerInvariant());
    if (!CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(sig)))
        return Results.Unauthorized();

    using JsonDocument evt = JsonDocument.Parse(raw);
    // Put the work on your own queue and answer 2xx right away; download event.url in the background
    return Results.NoContent();
});

app.Run();

Retries#

KindCounts as successScheduleAfter the last attempt
Embedded documentA 2xx status within 10 seconds; the response body is ignoredFirst try, after 10 seconds, after 60 seconds (3 in total)The gate logs at ERROR level and gives up; the file stays on the gate for the retention period
DocBuilder jobA 2xx statusImmediately, after 10 seconds, after 60 seconds (3 in total)See the job's callback field; files stay downloadable until result_ttl_minutes

Idempotent handling#

  • Store every X-O3O-Delivery you have processed; when it comes again, answer 2xx without redoing the work.
  • For document.saved the pair (key, version) is unique. A retry of version 2 may arrive AFTER version 3: only overwrite when version is greater than the one you hold.
  • Answer 2xx as soon as the event is recorded, then download url in the background and check sha256. The URL lives for O3O_EMBED_RETAIN_HOURS hours.
  • After document.closed the next opening MUST use a new document.key; reusing the old key during the retention period reopens the last working copy held by the gate.
  • For DocBuilder jobs, use job.id plus event as the de-duplication key.
Idempotent handler
import sqlite3

db = sqlite3.connect("o3o-callbacks.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS delivery (id TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS doc_version (doc_key TEXT PRIMARY KEY, version INTEGER NOT NULL, closed INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS download_queue (doc_key TEXT, version INTEGER, url TEXT, sha256 TEXT,
                                           PRIMARY KEY (doc_key, version));
""")


def handle_saved_or_closed(delivery_id: str, event: dict) -> None:
    """Call AFTER the signature has been verified. delivery_id comes from the X-O3O-Delivery header."""
    with db:  # one transaction: record the delivery and the version together
        if db.execute("SELECT 1 FROM delivery WHERE id = ?", (delivery_id,)).fetchone():
            return  # this delivery was already processed
        db.execute("INSERT INTO delivery(id) VALUES (?)", (delivery_id,))
        row = db.execute("SELECT version FROM doc_version WHERE doc_key = ?", (event["key"],)).fetchone()
        known = row[0] if row else 0
        if event["status"] == "saved" and event["version"] > known:
            db.execute("INSERT INTO doc_version(doc_key, version) VALUES (?, ?) "
                       "ON CONFLICT(doc_key) DO UPDATE SET version = excluded.version, closed = 0",
                       (event["key"], event["version"]))
            # another worker reads this table, downloads url and checks sha256
            db.execute("INSERT OR IGNORE INTO download_queue VALUES (?, ?, ?, ?)",
                       (event["key"], event["version"], event["url"], event["sha256"]))
        elif event["status"] == "closed":
            db.execute("INSERT INTO doc_version(doc_key, version, closed) VALUES (?, ?, 1) "
                       "ON CONFLICT(doc_key) DO UPDATE SET closed = 1",
                       (event["key"], event["version"]))
// In-memory illustration; use a database in production
const seenDeliveries = new Set();
const latestVersion = new Map();

export function handleSavedOrClosed(deliveryId, event, enqueueDownload, markClosed) {
  if (seenDeliveries.has(deliveryId)) return; // this delivery was already processed
  seenDeliveries.add(deliveryId);
  const known = latestVersion.get(event.key) ?? 0;
  if (event.status === "saved" && event.version > known) {
    latestVersion.set(event.key, event.version);
    enqueueDownload(event.key, event.version, event.url, event.sha256); // download in the background, check sha256
  } else if (event.status === "closed") {
    markClosed(event.key); // the next opening must use a new key
  }
}

Receiving callbacks on a DEV machine#

  • An application running on the Docker host itself: use http://host.docker.internal:<port>/... as callbackUrl and add host.docker.internal to O3O_FETCH_ALLOW_HOSTS.
  • With O3O_DEV_MODE=1 the gate has a built-in test receiver POST /o3o/demo/callback; GET /o3o/demo/callbacks returns the last 20 callbacks with their signature check result.