O3O DocBuilder và chuyển đổi
Việc bất đồng bộ và tệp kết quả
Chạy chuyển đổi, dựng tài liệu và điền mẫu ở chế độ bất đồng bộ: vòng đời job, GET /v1/jobs/{id}, GET /v1/files/{id}, thăm dò trạng thái, thời gian giữ kết quả và callback có chữ ký HMAC.
Trong trang này
Ba endpoint POST /v1/convert, POST /v1/build và POST /v1/template/render nhận tham số async. Với async = true, DocBuilder trả ngay 202 kèm đối tượng job và header Location; bạn hỏi trạng thái rồi tải từng tệp kết quả. Trích xuất luôn đồng bộ, không tạo job.
Khi nào nên dùng#
- Việc có thể dài hơn
sync_timeout_seconds(60 giây). - Kịch bản có nhiều phần tử trong
save: chế độ đồng bộ chỉ nhận một tệp ra. - Không muốn giữ kết nối HTTP mở, hoặc muốn nhận callback khi xong (bản doanh nghiệp).
Vòng đời của job#
status | Ý nghĩa |
|---|---|
queued | Đang chờ worker rảnh. |
running | Đang chạy trên một worker. |
done | Xong; outputs có các tệp kết quả. |
failed | Lỗi; error có cùng dạng với lỗi của API. |
| hết hạn | Job và tệp bị xoá hẳn; hỏi lại nhận 410 gone. |
Job chạy quá job_timeout_seconds của gói thì tiến trình LibreOffice của worker bị dừng và dựng lại, job chuyển sang failed với mã timeout.
Đối tượng Job#
Trường của Job
idstringbắt buộcjob_+ 24 ký tự hex.kindconvert | build | templatebắt buộcLoại việc.statusqueued | running | done | failedbắt buộcTrạng thái.created_atdate-timebắt buộcThời điểm tạo.started_at, finished_atdate-time | nulltuỳ chọnThời điểm bắt đầu và kết thúc.duration_msinteger | nulltuỳ chọnThời gian chạy, mili giây.expires_atdate-time | nulltuỳ chọnThời điểm job và các tệp của nó bị xoá.outputsarraybắt buộcCác tệp kết quả, xem bảng dưới.errorobject | nullbắt buộcChỉ có khifailed; cùng dạng vớierrorcủa API.callbackobject | nulltuỳ chọn{state: pending | delivered | failed, attempts}khi có callback.
Mỗi phần tử của outputs
file_idstringbắt buộcfile_+ 24 ký tự hex.urlstringbắt buộcO3O_PUBLIC_URL+/v1/files/{file_id}. Tải về vẫn cần headerAuthorization.filenamestringbắt buộcTên tệp.formatstringbắt buộcĐịnh dạng.content_typestringbắt buộcKiểu MIME.sizeintegerbắt buộcKích thước, byte.sha256stringbắt buộcSHA-256 của tệp.pagesinteger | nulltuỳ chọnSố trang nếu biết.expires_atdate-timebắt buộcThời điểm tệp bị xoá.
Hỏi trạng thái job#
/v1/jobs/{id}Trạng thái của một job.
Tham số
Authorizationheaderbắt buộcBearer <khoá API hoặc JWT>.idpath, stringbắt buộcMã job, mẫu^job_[0-9a-f]{24}$.
curl -sS http://localhost:8080/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024 \
-H "Authorization: Bearer O3O_DEMO_KEY"const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const res = await fetch(`${BASE_URL}/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024`, { headers: HEADERS });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
r = requests.get(f"{BASE_URL}/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024", headers=HEADERS, timeout=30)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())<?php
$ch = curl_init("http://localhost:8080/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
print_r(json_decode($body, true));using System.Net.Http.Headers;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
using var res = await http.GetAsync("http://localhost:8080/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024");
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());{
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "convert",
"status": "done",
"created_at": "2026-09-21T10:00:00Z",
"started_at": "2026-09-21T10:00:01Z",
"finished_at": "2026-09-21T10:00:03Z",
"duration_ms": 2000,
"expires_at": "2026-09-21T10:15:03Z",
"outputs": [
{
"file_id": "file_9b3e7d21c4a8f0e65d1b2c37",
"url": "http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37",
"filename": "bao-cao.pdf",
"format": "pdf",
"content_type": "application/pdf",
"size": 48213,
"sha256": "6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932",
"pages": 3,
"expires_at": "2026-09-21T10:15:03Z"
}
],
"error": null,
"callback": null
}{
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "build",
"status": "failed",
"created_at": "2026-09-21T10:00:00Z",
"started_at": "2026-09-21T10:00:01Z",
"finished_at": "2026-09-21T10:00:03Z",
"duration_ms": 2000,
"expires_at": "2026-09-21T10:15:03Z",
"outputs": [],
"error": {
"code": "timeout",
"message": "Job vượt quá thời gian chạy tối đa của gói.",
"detail": {},
"request_id": "req_7d1e4b9a2c05f836"
},
"callback": null
}Tải tệp kết quả#
/v1/files/{id}Tải một tệp kết quả.
Tham số
Authorizationheaderbắt buộcBearer <khoá API hoặc JWT>.idpath, stringbắt buộcMã tệp, mẫu^file_[0-9a-f]{24}$.
curl -sS http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37 \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-o bao-cao.pdf -w "HTTP %{http_code}\n"import { writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const res = await fetch(`${BASE_URL}/v1/files/file_9b3e7d21c4a8f0e65d1b2c37`, { headers: HEADERS });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("bao-cao.pdf", Buffer.from(await res.arrayBuffer()));import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
r = requests.get(f"{BASE_URL}/v1/files/file_9b3e7d21c4a8f0e65d1b2c37", headers=HEADERS, timeout=30)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("bao-cao.pdf", "wb") as fh:
fh.write(r.content)<?php
$ch = curl_init("http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
file_put_contents("bao-cao.pdf", $body);using System.Net.Http.Headers;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
using var res = await http.GetAsync("http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37");
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("bao-cao.pdf", await res.Content.ReadAsByteArrayAsync());ETag là SHA-256 của tệp trong dấu nháy kép, dùng để kiểm toàn vẹn; Content-Disposition có cả dạng filename* cho tên tiếng Việt.{
"X-O3O-Request-Id": "req_0123456789abcdef",
"Content-Disposition": "attachment; filename=\"bao-cao.pdf\"; filename*=UTF-8''bao-cao.pdf",
"ETag": "\"6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932\""
}Thăm dò đến khi xong rồi tải mọi tệp#
import os
import time
import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
def wait_for_job(job_id: str, timeout: float = 600) -> dict:
deadline = time.monotonic() + timeout
delay = 1.0
while True:
r = requests.get(f"{BASE_URL}/v1/jobs/{job_id}", headers=HEADERS, timeout=30)
r.raise_for_status()
job = r.json()
if job["status"] == "done":
return job
if job["status"] == "failed":
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
if time.monotonic() > deadline:
raise TimeoutError(job_id)
time.sleep(delay)
delay = min(delay * 1.5, 5.0)
job = wait_for_job("job_5f0c2a9e41b7d3c8a6e1f024")
for output in job["outputs"]:
r = requests.get(f"{BASE_URL}/v1/files/{output['file_id']}", headers=HEADERS, timeout=90)
r.raise_for_status()
with open(os.path.basename(output["filename"]), "wb") as fh:
fh.write(r.content)
print("Đã lưu", output["filename"], output["size"], "bytes")import { writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
async function waitForJob(jobId, timeoutMs = 600_000) {
const deadline = Date.now() + timeoutMs;
for (let delay = 1000; ; delay = Math.min(delay * 1.5, 5000)) {
const res = await fetch(`${BASE_URL}/v1/jobs/${jobId}`, { headers: HEADERS });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
const job = await res.json();
if (job.status === "done") return job;
if (job.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
if (Date.now() > deadline) throw new Error(`Quá thời gian chờ job ${jobId}`);
await sleep(delay);
}
}
const job = await waitForJob("job_5f0c2a9e41b7d3c8a6e1f024");
for (const output of job.outputs) {
const res = await fetch(`${BASE_URL}/v1/files/${output.file_id}`, { headers: HEADERS });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile(basename(output.filename), Buffer.from(await res.arrayBuffer()));
console.log("Đã lưu", output.filename);
}<?php
const BASE_URL = 'http://localhost:8080';
const API_KEY = 'O3O_DEMO_KEY';
function o3o_get(string $path): string
{
$ch = curl_init(BASE_URL . $path);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . API_KEY],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
return $body;
}
function wait_for_job(string $jobId, int $timeout = 600): array
{
$deadline = time() + $timeout;
for ($delay = 1.0; ; $delay = min($delay * 1.5, 5.0)) {
$job = json_decode(o3o_get("/v1/jobs/$jobId"), true);
if ($job['status'] === 'done') {
return $job;
}
if ($job['status'] === 'failed') {
throw new RuntimeException($job['error']['code'] . ': ' . $job['error']['message']);
}
if (time() > $deadline) {
throw new RuntimeException("Quá thời gian chờ job $jobId");
}
usleep((int) ($delay * 1_000_000));
}
}
$job = wait_for_job('job_5f0c2a9e41b7d3c8a6e1f024');
foreach ($job['outputs'] as $output) {
file_put_contents(basename($output['filename']), o3o_get('/v1/files/' . $output['file_id']));
echo "Đã lưu {$output['filename']}\n";
}using System.Net.Http.Headers;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:8080/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
async Task<JsonElement> WaitForJobAsync(string jobId, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
for (var delay = 1.0; ; delay = Math.Min(delay * 1.5, 5))
{
var job = JsonDocument.Parse(await http.GetStringAsync($"v1/jobs/{jobId}")).RootElement;
var status = job.GetProperty("status").GetString();
if (status == "done") return job;
if (status == "failed") throw new Exception(job.GetProperty("error").GetProperty("code").GetString());
if (DateTime.UtcNow > deadline) throw new TimeoutException(jobId);
await Task.Delay(TimeSpan.FromSeconds(delay));
}
}
var done = await WaitForJobAsync("job_5f0c2a9e41b7d3c8a6e1f024", TimeSpan.FromMinutes(10));
foreach (var output in done.GetProperty("outputs").EnumerateArray())
{
var fileId = output.GetProperty("file_id").GetString();
var name = Path.GetFileName(output.GetProperty("filename").GetString()!);
await File.WriteAllBytesAsync(name, await http.GetByteArrayAsync($"v1/files/{fileId}"));
Console.WriteLine($"Đã lưu {name}");
}JOB_ID="job_5f0c2a9e41b7d3c8a6e1f024"
AUTH="Authorization: Bearer O3O_DEMO_KEY"
while :; do
JOB=$(curl -sS "http://localhost:8080/v1/jobs/$JOB_ID" -H "$AUTH")
STATUS=$(printf '%s' "$JOB" | jq -r .status)
[ "$STATUS" = done ] && break
[ "$STATUS" = failed ] && { printf '%s\n' "$JOB" | jq .error; exit 1; }
sleep 2
done
printf '%s' "$JOB" | jq -r '.outputs[] | "\(.file_id) \(.filename)"' | while read -r FILE_ID NAME; do
curl -sS "http://localhost:8080/v1/files/$FILE_ID" -H "$AUTH" -o "$(basename "$NAME")"
doneThời gian giữ và giới hạn#
| Hạng mục | Bản cộng đồng | Bản doanh nghiệp |
|---|---|---|
Job chạy song song (parallel_jobs) | 1 | bằng số worker (O3O_DOCBUILDER_WORKERS) |
Job chờ tối đa trong hàng đợi (max_queued_jobs) | 10 | 1.000 |
Thời gian giữ job và tệp kết quả (result_ttl_minutes) | 15 phút | 1.440 phút (24 giờ) |
Thời gian chạy tối đa của một job (job_timeout_seconds) | 120 giây | 600 giây |
- Bộ dọn chạy mỗi phút, xoá job và tệp quá
result_ttl_minutes; sau đó mọi yêu cầu tới chúng trả410 gone. - Hàng đợi đầy (
max_queued_jobs) thì yêu cầu mới nhận503 queue_fullkèmRetry-After. - Hàng đợi nằm trong bộ nhớ: khởi động lại DocBuilder làm mất job đang chờ và đang chạy; tệp của job đã xong vẫn tải được tới khi hết hạn.
Callback khi job xong#
Bản doanh nghiệp có tính năng callback gửi được callback_url cùng yêu cầu. Khi job xong hoặc lỗi, DocBuilder gửi POST JSON {"event": "job.done" | "job.failed", "job": <Job>} tới URL đó, thử tối đa 3 lần: ngay lập tức, sau 10 giây, sau 60 giây. URL callback chịu quy tắc chống SSRF.
| Header | Nội dung |
|---|---|
X-O3O-Event | Tên sự kiện: job.done hoặc job.failed. |
X-O3O-Delivery | Mã của lần gửi, giữ nguyên qua các lần thử lại; dùng để bỏ bản trùng. |
X-O3O-Timestamp | Giây Unix lúc ký. |
X-O3O-Signature | sha256= + hex của HMAC-SHA256(O3O_DOCBUILDER_CALLBACK_SECRET, X-O3O-Timestamp + "." + thân nguyên văn). Vắng mặt khi máy chủ chưa đặt khoá. |
{
"event": "job.done",
"job": {
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "build",
"status": "done",
"created_at": "2026-09-21T10:00:00Z",
"started_at": "2026-09-21T10:00:01Z",
"finished_at": "2026-09-21T10:00:03Z",
"duration_ms": 2000,
"expires_at": "2026-09-22T10:00:03Z",
"outputs": [
{
"file_id": "file_9b3e7d21c4a8f0e65d1b2c37",
"url": "http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37",
"filename": "hop-dong.docx",
"format": "docx",
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"size": 9412,
"sha256": "6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932",
"pages": 1,
"expires_at": "2026-09-22T10:00:03Z"
},
{
"file_id": "file_2c7a91e0b54d3f86a1e9c0d4",
"url": "http://localhost:8080/v1/files/file_2c7a91e0b54d3f86a1e9c0d4",
"filename": "hop-dong.pdf",
"format": "pdf",
"content_type": "application/pdf",
"size": 30871,
"sha256": "4a8ed01db889effd4c25e561af1b01492509efaa6f2406f87e5bc3dd94c17235",
"pages": 1,
"expires_at": "2026-09-22T10:00:03Z"
}
],
"error": null,
"callback": {
"state": "delivered",
"attempts": 1
}
}
}import hashlib
import hmac
import time
def verify_callback(secret: str, headers: dict, raw_body: bytes, max_skew: int = 300) -> bool:
"""headers: header của yêu cầu, tên viết thường. raw_body: thân NGUYÊN VĂN, chưa phân tích JSON."""
timestamp = headers.get("x-o3o-timestamp", "")
signature = headers.get("x-o3o-signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > max_skew:
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), signature)import { createHmac, timingSafeEqual } from "node:crypto";
// headers: header của yêu cầu, tên viết thường. rawBody: Buffer thân NGUYÊN VĂN.
export function verifyCallback(secret, headers, rawBody, maxSkew = 300) {
const timestamp = headers["x-o3o-timestamp"] ?? "";
const signature = headers["x-o3o-signature"] ?? "";
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > maxSkew) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}<?php
// $headers: header của yêu cầu, tên viết thường. $rawBody = file_get_contents('php://input').
function verify_callback(string $secret, array $headers, string $rawBody, int $maxSkew = 300): bool
{
$timestamp = $headers['x-o3o-timestamp'] ?? '';
$signature = $headers['x-o3o-signature'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $maxSkew) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}using System.Security.Cryptography;
using System.Text;
// rawBody: thân NGUYÊN VĂN của yêu cầu, chưa phân tích JSON.
static bool VerifyCallback(string secret, string? timestamp, string? signature, byte[] rawBody, int maxSkew = 300)
{
if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > maxSkew)
return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var data = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature ?? ""));
}Mã lỗi thường gặp#
| HTTP | Mã | Khi nào |
|---|---|---|
| 401 | unauthorized | Thiếu hoặc sai khoá. detail.reason là missing, invalid hoặc no_keys_configured. |
| 404 | not_found | Không có job hoặc tệp với mã đó. |
| 410 | gone | Job hoặc tệp đã hết thời gian giữ. |
{
"error": {
"code": "not_found",
"message": "Không tìm thấy job.",
"detail": {},
"request_id": "req_0123456789abcdef"
}
}{
"error": {
"code": "gone",
"message": "Job đã hết thời gian giữ.",
"detail": {},
"request_id": "req_0123456789abcdef"
}
}Sắp có#
Huỷ job, hàng đợi bền vững qua khởi động lại, tải tệp lên trước rồi dùng lại nhiều lần (POST /v1/files).