Bỏ qua, tới nội dung
Tài liệu APIv1

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ạiAi gửiTới đâuSự kiệnKhoá kýGói
Lưu tài liệu nhúngo3o-gateeditor.callbackUrldocument.saved, document.closedO3O_EMBED_CALLBACK_SECRETCả hai bản
Job DocBuilder xongo3o-docbuildercallback_url của yêu cầujob.done, job.failedO3O_DOCBUILDER_CALLBACK_SECRETChỉ 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-8User-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.

JSONThân callback <code>document.saved</code>
{
  "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ộc
    saved: 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
    Đúng document.key của config.
  • versionintegerbắt buộc
    Tăng dần từ 1. Với closed là phiên bản cuối, 0 nếu chưa từng lưu.
  • urlstringbắt buộc
    URL tải phiên bản đó, có chữ ký, sống O3O_EMBED_RETAIN_HOURS giờ. null khi closedversion = 0.
  • fileTypestringbắt buộc
    Đuôi tệp, ví dụ docx.
  • titlestringbắt buộc
    Tên hiển thị của tài liệu.
  • sizeintegerbắt buộc
    Kích thước tệp, byte.
  • sha256stringbắt buộc
    SHA-256 của tệp, để bạn kiểm sau khi tải.
  • usersarraybắt buộc
    Những người đang mở tài liệu lúc gửi. Mảng rỗng với closed.
  • savedByobjectbắt buộc
    Người sở hữu phiên gây ra lần lưu. null với closed.
  • autosavebooleanbắt buộc
    Lần lưu do máy chủ tự làm.
  • finalbooleanbắt buộc
    Lần lưu khi người cuối cùng rời tài liệu; sau bản final sẽ có callback closed.
  • modifiedByUserbooleanbắt buộc
    Có thay đổi do người dùng kể từ lần lưu trước.
  • timestampstringbắt buộc
    Thờ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/renderasync = truecallback_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.

JSONThân callback <code>job.done</code> (giá trị minh hoạ)
{
  "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ý#

HeaderNội dung
X-O3O-Eventdocument.saved, document.closed, job.done hoặc job.failed
X-O3O-DeliveryUUID của lần gửi; GIỮ NGUYÊN qua các lần thử lại
X-O3O-TimestampGiây Unix lúc ký
X-O3O-Signaturesha256= + 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á.
  1. Đọc thân yêu cầu NGUYÊN VĂN dưới dạng byte, trước khi phân tích JSON.
  2. Tính sha256= + hex HMAC SHA-256 của timestamp + "." + thân bằng khoá dùng chung.
  3. So với X-O3O-Signature bằng hàm so sánh thời gian hằng.
  4. Từ chối khi X-O3O-Timestamp lệch quá 300 giây so với đồng hồ của bạn.
  5. Chỉ sau đó mới phân tích JSON và xử lý.
Kiểm chữ ký callback
// 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ạiThành công khiLịch gửiSau lần cuối
Lưu tài liệu nhúngMã 2xx trong vòng 10 giây; thân trả lời bị bỏ quaLầ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 DocBuilderMã 2xxNgay, 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 đè khi version lớ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ểm sha256. URL sống O3O_EMBED_RETAIN_HOURS giờ.
  • Sau document.closed, lần mở kế tiếp PHẢI dùng document.key mớ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.id cộng event làm khoá chống trùng.
Bộ xử lý idempotent
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àm callbackUrl và thêm host.docker.internal vào O3O_FETCH_ALLOW_HOSTS.
  • Với O3O_DEV_MODE=1, gate có sẵn bộ nhận thử POST /o3o/demo/callback; GET /o3o/demo/callbacks trả 20 callback gần nhất kèm kết quả kiểm chữ ký.