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.
| Kind | Sender | Destination | Events | Signing key | Edition |
|---|---|---|---|---|---|
| Embedded document saved | o3o-gate | editor.callbackUrl | document.saved, document.closed | O3O_EMBED_CALLBACK_SECRET | Both editions |
| DocBuilder job finished | o3o-docbuilder | callback_url of the request | job.done, job.failed | O3O_DOCBUILDER_CALLBACK_SECRET | Enterprise 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.
{
"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
statusstringrequiredsaved: a new version exists and the document may still be open.closed: everyone has left (no view for 30 consecutive seconds).keystringrequiredExactly thedocument.keyof the config.versionintegerrequiredIncreases from 1. Forclosedit is the last version,0if nothing was ever saved.urlstringrequiredSigned download URL of that version, valid forO3O_EMBED_RETAIN_HOURShours.nullforclosedwithversion = 0.fileTypestringrequiredFile extension, for exampledocx.titlestringrequiredDisplay name of the document.sizeintegerrequiredFile size in bytes.sha256stringrequiredSHA-256 of the file, to check after download.usersarrayrequiredPeople who had the document open when it was sent. Empty forclosed.savedByobjectrequiredOwner of the session that caused the save.nullforclosed.autosavebooleanrequiredThe save was made automatically by the server.finalbooleanrequiredSave made when the last person left; aclosedcallback follows afinalone.modifiedByUserbooleanrequiredThe user changed something since the previous save.timestampstringrequiredSend 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.
{
"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#
| Header | Content |
|---|---|
X-O3O-Event | document.saved, document.closed, job.done or job.failed |
X-O3O-Delivery | UUID of the delivery; UNCHANGED across retries |
X-O3O-Timestamp | Unix seconds at signing time |
X-O3O-Signature | sha256= + hex of HMAC_SHA256(key, X-O3O-Timestamp + "." + raw_body). Absent when the server has no key. |
- Read the request body RAW as bytes, before parsing JSON.
- Compute
sha256=+ hex HMAC SHA-256 oftimestamp + "." + bodywith the shared key. - Compare with
X-O3O-Signatureusing a constant-time comparison. - Reject when
X-O3O-Timestampis more than 300 seconds away from your clock. - Only then parse the JSON and process it.
// 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#
| Kind | Counts as success | Schedule | After the last attempt |
|---|---|---|---|
| Embedded document | A 2xx status within 10 seconds; the response body is ignored | First 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 job | A 2xx status | Immediately, 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-Deliveryyou have processed; when it comes again, answer 2xx without redoing the work. - For
document.savedthe pair (key,version) is unique. A retry of version 2 may arrive AFTER version 3: only overwrite whenversionis greater than the one you hold. - Answer 2xx as soon as the event is recorded, then download
urlin the background and checksha256. The URL lives forO3O_EMBED_RETAIN_HOURShours. - After
document.closedthe next opening MUST use a newdocument.key; reusing the old key during the retention period reopens the last working copy held by the gate. - For DocBuilder jobs, use
job.idpluseventas the de-duplication key.
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>/...ascallbackUrland addhost.docker.internaltoO3O_FETCH_ALLOW_HOSTS. - With
O3O_DEV_MODE=1the gate has a built-in test receiverPOST /o3o/demo/callback;GET /o3o/demo/callbacksreturns the last 20 callbacks with their signature check result.