O3O DocBuilder and conversion
Async jobs and result files
Run conversion, building and template filling asynchronously: the job lifecycle, GET /v1/jobs/{id}, GET /v1/files/{id}, polling, result retention and HMAC-signed callbacks.
On this page
The endpoints POST /v1/convert, POST /v1/build and POST /v1/template/render take an async parameter. With async = true, DocBuilder answers 202 at once with a job object and a Location header; you poll the status and then download each result file. Extraction is always synchronous and creates no job.
When to use it#
- Work that may take longer than
sync_timeout_seconds(60 seconds). - Scripts with several
saveentries: synchronous mode allows one output only. - You do not want to hold an HTTP connection open, or you want a callback when done (Enterprise).
Job lifecycle#
status | Meaning |
|---|---|
queued | Waiting for a free worker. |
running | Running on a worker. |
done | Finished; outputs lists the result files. |
failed | Failed; error has the same shape as API errors. |
| expired | The job and its files are deleted; asking again returns 410 gone. |
A job running past the plan's job_timeout_seconds has its worker's LibreOffice process killed and restarted, and the job becomes failed with the code timeout.
The Job object#
Job fields
idstringrequiredjob_+ 24 hex characters.kindconvert | build | templaterequiredKind of work.statusqueued | running | done | failedrequiredStatus.created_atdate-timerequiredCreation time.started_at, finished_atdate-time | nulloptionalStart and end time.duration_msinteger | nulloptionalRun time in milliseconds.expires_atdate-time | nulloptionalWhen the job and its files are deleted.outputsarrayrequiredResult files, see below.errorobject | nullrequiredOnly whenfailed; same shape as the APIerror.callbackobject | nulloptional{state: pending | delivered | failed, attempts}when a callback is set.
Each outputs entry
file_idstringrequiredfile_+ 24 hex characters.urlstringrequiredO3O_PUBLIC_URL+/v1/files/{file_id}. Downloading still needs theAuthorizationheader.filenamestringrequiredFile name.formatstringrequiredFormat.content_typestringrequiredMIME type.sizeintegerrequiredSize in bytes.sha256stringrequiredSHA-256 of the file.pagesinteger | nulloptionalPage count when known.expires_atdate-timerequiredWhen the file is deleted.
Getting a job#
/v1/jobs/{id}Status of one job.
Parameters
AuthorizationheaderrequiredBearer <API key or JWT>.idpath, stringrequiredJob ID, pattern^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
}Downloading a result file#
/v1/files/{id}Download one result file.
Parameters
AuthorizationheaderrequiredBearer <API key or JWT>.idpath, stringrequiredFile ID, pattern^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 is the file's SHA-256 in double quotes, useful for integrity checks; Content-Disposition also carries a filename* form for Vietnamese names.{
"X-O3O-Request-Id": "req_0123456789abcdef",
"Content-Disposition": "attachment; filename=\"bao-cao.pdf\"; filename*=UTF-8''bao-cao.pdf",
"ETag": "\"6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932\""
}Poll until done, then download every file#
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("Saved", 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(`Timed out waiting for 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("Saved", 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("Timed out waiting for 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 "Saved {$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($"Saved {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")"
doneRetention and limits#
| Item | Community | Enterprise |
|---|---|---|
Parallel jobs (parallel_jobs) | 1 | equal to the worker count (O3O_DOCBUILDER_WORKERS) |
Maximum queued jobs (max_queued_jobs) | 10 | 1,000 |
Retention of jobs and result files (result_ttl_minutes) | 15 minutes | 1,440 minutes (24 hours) |
Job run time limit (job_timeout_seconds) | 120 seconds | 600 seconds |
- A cleaner runs every minute and deletes jobs and files past
result_ttl_minutes; after that every request for them returns410 gone. - When the queue is full (
max_queued_jobs) new requests get503 queue_fullwithRetry-After. - The queue lives in memory: restarting DocBuilder loses queued and running jobs; files of finished jobs remain downloadable until they expire.
Completion callback#
Enterprise with the callback feature can send callback_url with the request. When the job finishes or fails, DocBuilder sends a JSON POST {"event": "job.done" | "job.failed", "job": <Job>} to that URL, trying up to 3 times: immediately, after 10 seconds, after 60 seconds. The callback URL is subject to the SSRF rules.
| Header | Content |
|---|---|
X-O3O-Event | Event name: job.done or job.failed. |
X-O3O-Delivery | Delivery ID, unchanged across retries; use it to drop duplicates. |
X-O3O-Timestamp | Unix time, in seconds, of signing. |
X-O3O-Signature | sha256= + hex of HMAC-SHA256(O3O_DOCBUILDER_CALLBACK_SECRET, X-O3O-Timestamp + "." + raw body). Absent when the server has no secret set. |
{
"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: request headers with lower-case names. raw_body: the RAW body, before JSON parsing."""
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: request headers with lower-case names. rawBody: Buffer holding the RAW body.
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: request headers with lower-case names. $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: the RAW request body, before JSON parsing.
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 ?? ""));
}Common errors#
| HTTP | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or wrong key. detail.reason is missing, invalid or no_keys_configured. |
| 404 | not_found | No job or file with that ID. |
| 410 | gone | The job or file is past its retention time. |
{
"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"
}
}Coming later#
Job cancellation, a queue that survives restarts, and uploading a file once for reuse (POST /v1/files).