Nền tảng
Callback và webhook
Callback khi tài liệu nhúng được lưu và khi job DocBuilder xong: định dạng, chữ ký HMAC SHA-256, lịch thử lại và cách xử lý idempotent.
Trong trang này
O3O chủ động gọi ra máy chủ của bạn ở hai chỗ: khi tài liệu nhúng có bản lưu mới, và khi một job bất đồng bộ của DocBuilder xong việc. Cả hai dùng chung cách ký, cách thử lại và cùng bộ header.
| Loại | Ai gửi | Tới đâu | Sự kiện | Khoá ký | Gói |
|---|---|---|---|---|---|
| Lưu tài liệu nhúng | o3o-gate | editor.callbackUrl | document.saved, document.closed | O3O_EMBED_CALLBACK_SECRET | Cả hai bản |
| Job DocBuilder xong | o3o-docbuilder | callback_url của yêu cầu | job.done, job.failed | O3O_DOCBUILDER_CALLBACK_SECRET | Chỉ bản doanh nghiệp, token có tính năng callback |
Callback lưu tài liệu nhúng#
Gate gửi POST với Content-Type: application/json; charset=utf-8 và User-Agent: O3O-Gate/1.0. Gate không theo chuyển hướng, và callbackUrl chịu quy tắc chống SSRF: địa chỉ nội bộ bị từ chối trừ khi nằm trong O3O_FETCH_ALLOW_HOSTS. Việc lưu không bao giờ chờ callback: gate ghi phiên bản mới, trả lời máy chủ soạn thảo ngay, rồi mới gửi callback ở nền.
{
"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"
}Trường của thân callback
statusstringbắt buộcsaved: có phiên bản mới, tài liệu có thể vẫn đang mở.closed: mọi người đã rời (không còn view nào trong 30 giây liên tục).keystringbắt buộcĐúngdocument.keycủa config.versionintegerbắt buộcTăng dần từ 1. Vớiclosedlà phiên bản cuối,0nếu chưa từng lưu.urlstringbắt buộcURL tải phiên bản đó, có chữ ký, sốngO3O_EMBED_RETAIN_HOURSgiờ.nullkhiclosedvàversion = 0.fileTypestringbắt buộcĐuôi tệp, ví dụdocx.titlestringbắt buộcTên hiển thị của tài liệu.sizeintegerbắt buộcKích thước tệp, byte.sha256stringbắt buộcSHA-256 của tệp, để bạn kiểm sau khi tải.usersarraybắt buộcNhững người đang mở tài liệu lúc gửi. Mảng rỗng vớiclosed.savedByobjectbắt buộcNgười sở hữu phiên gây ra lần lưu.nullvớiclosed.autosavebooleanbắt buộcLần lưu do máy chủ tự làm.finalbooleanbắt buộcLần lưu khi người cuối cùng rời tài liệu; sau bảnfinalsẽ có callbackclosed.modifiedByUserbooleanbắt buộcCó thay đổi do người dùng kể từ lần lưu trước.timestampstringbắt buộcThời điểm gửi, ISO 8601 UTC.
Callback job DocBuilder#
Khi yêu cầu POST /v1/convert, /v1/build hoặc /v1/template/render có async = true và callback_url, DocBuilder gửi POST JSON {"event": ..., "job": ...} khi job xong (job.done) hoặc hỏng (job.failed), với User-Agent: O3O-DocBuilder/1.0 và đúng bộ header ký như trên, dùng khoá O3O_DOCBUILDER_CALLBACK_SECRET. job có cùng lược đồ với GET /v1/jobs/{id}. Tải tệp ở outputs[].url vẫn cần header Authorization.
{
"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
}
}Trường callback của GET /v1/jobs/{id} cho biết tình trạng giao nhận (pending, delivered, failed) và số lần đã thử, tiện đối chiếu khi nghi callback bị lạc.
Header và chữ ký#
| Header | Nội dung |
|---|---|
X-O3O-Event | document.saved, document.closed, job.done hoặc job.failed |
X-O3O-Delivery | UUID của lần gửi; GIỮ NGUYÊN qua các lần thử lại |
X-O3O-Timestamp | Giây Unix lúc ký |
X-O3O-Signature | sha256= + hex của HMAC_SHA256(khoá, X-O3O-Timestamp + "." + thân_nguyên_văn). Vắng mặt khi máy chủ chưa đặt khoá. |
- Đọc thân yêu cầu NGUYÊN VĂN dưới dạng byte, trước khi phân tích JSON.
- Tính
sha256=+ hex HMAC SHA-256 củatimestamp + "." + thânbằng khoá dùng chung. - So với
X-O3O-Signaturebằng hàm so sánh thời gian hằng. - Từ chối khi
X-O3O-Timestamplệch quá 300 giây so với đồng hồ của bạn. - Chỉ sau đó mới phân tích JSON và xử lý.
// npm install express (tệp .mjs hoặc "type": "module")
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.O3O_EMBED_CALLBACK_SECRET; // khoá trùng với O3O_EMBED_CALLBACK_SECRET của O3O
const app = express();
function verify(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
// Từ chối khi dấu thời gian lệch quá 300 giây
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);
}
// Ký trên thân NGUYÊN VĂN, chưa phân tích JSON
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"));
// Xếp việc vào hàng đợi của bạn rồi trả 2xx ngay; tải event.url ở nền
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() # khoá trùng với O3O_EMBED_CALLBACK_SECRET của O3O
app = FastAPI()
def verify(raw: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature:
return False
try:
# Từ chối khi dấu thời gian lệch quá 300 giây
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() # Ký trên thân NGUYÊN VĂN, chưa phân tích JSON
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)
# Xếp việc vào hàng đợi của bạn rồi trả 2xx ngay; tải event.url ở nền
return Response(status_code=204)<?php
$secret = getenv('O3O_EMBED_CALLBACK_SECRET'); // khoá trùng với O3O_EMBED_CALLBACK_SECRET của O3O
$raw = file_get_contents('php://input'); // Ký trên thân NGUYÊN VĂN, chưa phân tích JSON
$ts = $_SERVER['HTTP_X_O3O_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_O3O_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
// Từ chối khi dấu thời gian lệch quá 300 giây
if ($ts === '' || !ctype_digit($ts) || abs(time() - (int)$ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Xếp việc vào hàng đợi của bạn rồi trả 2xx ngay; tải event.url ở nền
http_response_code(204);using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var app = WebApplication.CreateBuilder(args).Build();
// khoá trùng với O3O_EMBED_CALLBACK_SECRET của O3O
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(); // Ký trên thân NGUYÊN VĂN, chưa phân tích JSON
string ts = request.Headers["X-O3O-Timestamp"].ToString();
string sig = request.Headers["X-O3O-Signature"].ToString();
// Từ chối khi dấu thời gian lệch quá 300 giây
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);
// Xếp việc vào hàng đợi của bạn rồi trả 2xx ngay; tải event.url ở nền
return Results.NoContent();
});
app.Run();Thử lại#
| Loại | Thành công khi | Lịch gửi | Sau lần cuối |
|---|---|---|---|
| Lưu tài liệu nhúng | Mã 2xx trong vòng 10 giây; thân trả lời bị bỏ qua | Lần đầu, sau 10 giây, sau 60 giây (tổng 3 lần) | Gate ghi nhật ký mức ERROR và bỏ; tệp vẫn nằm ở gate trong thời gian giữ |
| Job DocBuilder | Mã 2xx | Ngay, sau 10 giây, sau 60 giây (tổng 3 lần) | Xem trường callback của job; tệp tải được tới hết result_ttl_minutes |
Xử lý idempotent#
- Lưu
X-O3O-Deliveryđã xử lý; gặp lại thì trả 2xx ngay mà không làm lại. - Với
document.saved, cặp (key,version) là duy nhất. Lần thử lại của bản 2 có thể đến SAU bản 3: chỉ ghi đè khiversionlớn hơn bản bạn đang giữ. - Trả 2xx ngay khi đã ghi nhận, rồi tải
urlở nền và kiểmsha256. URL sốngO3O_EMBED_RETAIN_HOURSgiờ. - Sau
document.closed, lần mở kế tiếp PHẢI dùngdocument.keymới; dùng lại key cũ trong thời gian giữ sẽ mở lại bản làm việc cuối mà gate đang giữ. - Với job DocBuilder, dùng
job.idcộngeventlàm khoá chống trùng.
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:
"""Gọi SAU khi đã kiểm chữ ký. delivery_id lấy từ header X-O3O-Delivery."""
with db: # một giao dịch: ghi nhận lần gửi và phiên bản cùng lúc
if db.execute("SELECT 1 FROM delivery WHERE id = ?", (delivery_id,)).fetchone():
return # lần gửi này đã xử lý
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"]))
# một worker khác đọc bảng này, tải url và kiểm 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"]))// Minh hoạ trong bộ nhớ; chạy thật hãy lưu vào CSDL
const seenDeliveries = new Set();
const latestVersion = new Map();
export function handleSavedOrClosed(deliveryId, event, enqueueDownload, markClosed) {
if (seenDeliveries.has(deliveryId)) return; // lần gửi này đã xử lý
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); // tải ở nền, kiểm sha256
} else if (event.status === "closed") {
markClosed(event.key); // lần mở sau phải dùng key mới
}
}Nhận callback trên máy DEV#
- Ứng dụng chạy ngay trên máy chủ Docker: dùng
http://host.docker.internal:<cổng>/...làmcallbackUrlvà thêmhost.docker.internalvàoO3O_FETCH_ALLOW_HOSTS. - Với
O3O_DEV_MODE=1, gate có sẵn bộ nhận thửPOST /o3o/demo/callback;GET /o3o/demo/callbackstrả 20 callback gần nhất kèm kết quả kiểm chữ ký.