Skip to content

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 save entries: 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#

statusMeaning
queuedWaiting for a free worker.
runningRunning on a worker.
doneFinished; outputs lists the result files.
failedFailed; error has the same shape as API errors.
expiredThe 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

  • idstringrequired
    job_ + 24 hex characters.
  • kindconvert | build | templaterequired
    Kind of work.
  • statusqueued | running | done | failedrequired
    Status.
  • created_atdate-timerequired
    Creation time.
  • started_at, finished_atdate-time | nulloptional
    Start and end time.
  • duration_msinteger | nulloptional
    Run time in milliseconds.
  • expires_atdate-time | nulloptional
    When the job and its files are deleted.
  • outputsarrayrequired
    Result files, see below.
  • errorobject | nullrequired
    Only when failed; same shape as the API error.
  • callbackobject | nulloptional
    {state: pending | delivered | failed, attempts} when a callback is set.

Each outputs entry

  • file_idstringrequired
    file_ + 24 hex characters.
  • urlstringrequired
    O3O_PUBLIC_URL + /v1/files/{file_id}. Downloading still needs the Authorization header.
  • filenamestringrequired
    File name.
  • formatstringrequired
    Format.
  • content_typestringrequired
    MIME type.
  • sizeintegerrequired
    Size in bytes.
  • sha256stringrequired
    SHA-256 of the file.
  • pagesinteger | nulloptional
    Page count when known.
  • expires_atdate-timerequired
    When the file is deleted.

Getting a job#

GET/v1/jobs/{id}

Status of one job.

Auth: BearerCommunityEnterprise

Parameters

  • Authorizationheaderrequired
    Bearer <API key or JWT>.
  • idpath, stringrequired
    Job ID, pattern ^job_[0-9a-f]{24}$.
Get a job
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());
200A finished conversion job (Community, kept 15 minutes). Time values only illustrate the structure; they are not measurements.
{
  "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
}
200A job that failed by running out of time.
{
  "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#

GET/v1/files/{id}

Download one result file.

Auth: BearerCommunityEnterprise

Parameters

  • Authorizationheaderrequired
    Bearer <API key or JWT>.
  • idpath, stringrequired
    File ID, pattern ^file_[0-9a-f]{24}$.
Download a file
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());
200The body is the file. 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#

Wait for a job and download the results
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")"
done

Retention and limits#

ItemCommunityEnterprise
Parallel jobs (parallel_jobs)1equal to the worker count (O3O_DOCBUILDER_WORKERS)
Maximum queued jobs (max_queued_jobs)101,000
Retention of jobs and result files (result_ttl_minutes)15 minutes1,440 minutes (24 hours)
Job run time limit (job_timeout_seconds)120 seconds600 seconds
  • A cleaner runs every minute and deletes jobs and files past result_ttl_minutes; after that every request for them returns 410 gone.
  • When the queue is full (max_queued_jobs) new requests get 503 queue_full with Retry-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.

HeaderContent
X-O3O-EventEvent name: job.done or job.failed.
X-O3O-DeliveryDelivery ID, unchanged across retries; use it to drop duplicates.
X-O3O-TimestampUnix time, in seconds, of signing.
X-O3O-Signaturesha256= + hex of HMAC-SHA256(O3O_DOCBUILDER_CALLBACK_SECRET, X-O3O-Timestamp + "." + raw body). Absent when the server has no secret set.
JSONCallback body
{
  "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
    }
  }
}
Verifying the callback signature
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#

HTTPCodeWhen
401unauthorizedMissing or wrong key. detail.reason is missing, invalid or no_keys_configured.
404not_foundNo job or file with that ID.
410goneThe job or file is past its retention time.
404No job with that ID.
{
  "error": {
    "code": "not_found",
    "message": "Không tìm thấy job.",
    "detail": {},
    "request_id": "req_0123456789abcdef"
  }
}
410The job is past its retention time.
{
  "error": {
    "code": "gone",
    "message": "Job đã hết thời gian giữ.",
    "detail": {},
    "request_id": "req_0123456789abcdef"
  }
}

Coming later#

Coming soon

Job cancellation, a queue that survives restarts, and uploading a file once for reuse (POST /v1/files).