O3O DocBuilder and conversion
O3O DocBuilder overview
O3O DocBuilder is a server-side REST API for documents: format conversion, building text documents, spreadsheets and presentations from a JSON script, template filling, and extracting text, metadata and thumbnails, with no editor involved.
On this page
DocBuilder is a separate service in the O3O Office Online Docker bundle, served under /v1/ behind the proxy. Your system sends HTTP requests from its back end, and DocBuilder answers with a result file or JSON data. There is no user interface and no editing session, and end users never need to open a browser.
When to use it#
| You need to | Endpoint | See |
|---|---|---|
| Change formats: docx to PDF, xlsx to CSV, one slide to an image | POST /v1/convert | Format conversion |
| Generate quotations, payroll sheets or slide decks from data you already hold | POST /v1/build | Building documents, o3oscript |
| Fill data into Word templates written by business staff | POST /v1/template/render | Template filling (Enterprise) |
| Pull text for a search index, read metadata, create thumbnails | POST /v1/extract/text, POST /v1/extract/meta, POST /v1/extract/thumbnail | Extraction |
| Run long jobs or multi-output builds without holding the HTTP connection open | GET /v1/jobs/{id}, GET /v1/files/{id} | Async jobs |
How DocBuilder runs in v1#
- One container,
o3o-docbuilder: Debian bookworm, LibreOffice from the Debian repository,python3-uno,poppler-utilsand FastAPI, internal port 8060. - Inside is a pool of
O3O_DOCBUILDER_WORKERSheadless LibreOffice processes (defaultmin(CPU cores, 4), configurable from 1 to 32). Each process has its own user profile and handles one job at a time. - The queue and job state live in memory. Restarting the service loses queued and running jobs; finished jobs keep their files downloadable until they expire.
- A worker is recycled after
O3O_DOCBUILDER_WORKER_MAX_JOBSjobs (default 200), afterO3O_DOCBUILDER_WORKER_MAX_AGE_MINUTESminutes (default 60), and after every timeout or crash. - Macros never run: every file loaded through LibreOffice is opened with macro execution off and external link updates off. Template filling does not load the template through LibreOffice, so a template that contains macros is rejected up front with
422 macro_not_allowed; see Template filling. - The LibreOffice version actually running is reported by
GET /v1/statusin thecorefield. DocBuilder v1 uses the Debian LibreOffice build, not yet the O3O 26.8 core.
Licenses of the open-source software in the image#
- LibreOffice is open-source software stewarded by The Document Foundation together with its contributor community. Its main license is MPL 2.0; some files are under Apache 2.0, the LGPL and other third-party licenses. The image installs the Debian bookworm packages as they are and does not modify LibreOffice code.
- The other packages in the image keep their own licenses;
poppler-utils, for example, is under the GPL. Each package's license text ships inside the image at/usr/share/doc/<package>/copyright. - The source code for the exact package versions is available from the Debian bookworm source archive (sources.debian.org). If you redistribute the image to others, keep the license files intact and tell recipients how to obtain the source. The MPL 2.0 obligations are summarised on Building from source; this section is not legal advice.
- O3O DocBuilder's own code (the API service, the worker pool, template filling, the o3oscript builder) is owned by O3O and does not modify LibreOffice code. The image ships WITH LibreOffice, so distributing the image means including the license notices of LibreOffice and the Debian packages and stating where their source can be obtained.
- LibreOffice is a trademark of The Document Foundation; this documentation uses the name only to identify the component running inside DocBuilder.
Base URL#
Every example uses the sample server http://localhost:8080, the proxy of the Docker bundle on a developer machine; the proxy forwards every /v1/ path to DocBuilder. In production, replace it with your public address (the O3O_PUBLIC_URL variable). The proxy does not buffer request bodies and waits up to 660 seconds; file size limits are enforced by DocBuilder according to the plan.
Authentication#
Every endpoint except GET /v1/status requires the header Authorization: Bearer <value>. The value is one of the two kinds below.
| Kind | Server setting | Rules |
|---|---|---|
| API key | O3O_DOCBUILDER_API_KEYS: a comma-separated list, each item either name:key or just key. | Must match ^[A-Za-z0-9_-]{24,128}$; starting with o3o_ is recommended. The server compares keys in constant time. |
| JWT HS256 (optional) | O3O_DOCBUILDER_JWT_SECRET, at least 32 characters. Leave it empty to disable JWT. | A value with exactly two dots is treated as a JWT. exp is required, the remaining lifetime must not exceed 24 hours, and 60 seconds of clock skew is allowed. sub (optional) labels the caller in logs. Any algorithm other than HS256, including none, is rejected. |
Issuing a short-lived JWT#
Use a JWT when you want to give each subsystem time-limited access instead of sharing a long-lived API key. Your server and DocBuilder share the secret O3O_DOCBUILDER_JWT_SECRET; the example signs a JWT valid for one hour and calls GET /v1/limits.
import os
import time
import jwt # PyJWT package
import requests
secret = os.environ["O3O_DOCBUILDER_JWT_SECRET"]
token = jwt.encode({"sub": "crm-noi-bo", "exp": int(time.time()) + 3600}, secret, algorithm="HS256")
r = requests.get("http://localhost:8080/v1/limits", headers={"Authorization": f"Bearer {token}"}, timeout=30)
print(r.status_code, r.json())import { createHmac } from "node:crypto";
const b64url = (data) => Buffer.from(data).toString("base64url");
function signJwt(secret, sub, ttlSeconds = 3600) {
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = b64url(JSON.stringify({ sub, exp: Math.floor(Date.now() / 1000) + ttlSeconds }));
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}
const token = signJwt(process.env.O3O_DOCBUILDER_JWT_SECRET, "crm-noi-bo");
const res = await fetch("http://localhost:8080/v1/limits", { headers: { Authorization: `Bearer ${token}` } });
console.log(res.status, await res.json());<?php
function b64url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function sign_jwt(string $secret, string $sub, int $ttl = 3600): string
{
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = b64url(json_encode(['sub' => $sub, 'exp' => time() + $ttl]));
$signature = b64url(hash_hmac('sha256', "$header.$payload", $secret, true));
return "$header.$payload.$signature";
}
$token = sign_jwt(getenv('O3O_DOCBUILDER_JWT_SECRET'), 'crm-noi-bo');
$ch = curl_init('http://localhost:8080/v1/limits');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch);using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
static string B64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static string SignJwt(string secret, string sub, int ttlSeconds = 3600)
{
var header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
var exp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + ttlSeconds;
var payload = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { sub, exp }));
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signature = B64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{header}.{payload}")));
return $"{header}.{payload}.{signature}";
}
var token = SignJwt(Environment.GetEnvironmentVariable("O3O_DOCBUILDER_JWT_SECRET")!, "crm-noi-bo");
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine(await http.GetStringAsync("http://localhost:8080/v1/limits"));b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
SECRET="$O3O_DOCBUILDER_JWT_SECRET"
HEADER=$(printf '{"alg":"HS256","typ":"JWT"}' | b64url)
PAYLOAD=$(printf '{"sub":"crm-noi-bo","exp":%d}' "$(( $(date +%s) + 3600 ))" | b64url)
SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)
TOKEN="$HEADER.$PAYLOAD.$SIGNATURE"
curl -sS http://localhost:8080/v1/limits -H "Authorization: Bearer $TOKEN"Plans and limits#
The DocBuilder plan comes from the license token loaded on the server: it is Enterprise when the token is valid or grace and includes the api_full feature; anything else is Community. Limits apply to the whole instance, not per API key. The variables O3O_DOCBUILDER_MAX_FILE_MB and O3O_DOCBUILDER_JOB_TIMEOUT_SECONDS can only lower the plan limits, never raise them.
| Item | Community | Enterprise |
|---|---|---|
Counted requests per minute (rate_per_minute) | 10 | clamp(60 * ceil(conns / 50), 60, 3000), where conns is the connection count in the token |
Counted requests per day (rate_per_day) | 200 | unlimited |
Parallel jobs (parallel_jobs) | 1 | equal to the worker count (O3O_DOCBUILDER_WORKERS) |
Maximum queued jobs (max_queued_jobs) | 10 | 1,000 |
Maximum input or output file size (max_file_mb) | 10 MB | 300 MB |
Retention of jobs and result files (result_ttl_minutes) | 15 minutes | 1,440 minutes (24 hours) |
Synchronous mode time limit (sync_timeout_seconds) | 60 seconds | 60 seconds |
Job run time limit (job_timeout_seconds) | 120 seconds | 600 seconds |
Asynchronous mode (async) | yes | yes |
Job completion callback (callback) | no | yes, when the token has the callback feature |
Maximum script units (max_script_units) | 500 | 20,000 |
Maximum outputs per build (max_outputs_per_build) | 1 | 5 |
Community can call every endpoint except POST /v1/template/render. Examples of the Enterprise per-minute limit by the connection count in the token: 50 → 60, 120 → 180, 200 → 240, 201 → 300, 500 → 600, 800 → 960, 2,500 → 3,000, 5,000 → 3,000.
- Only work requests are counted:
POST /v1/convert,POST /v1/build,POST /v1/template/render,POST /v1/extract/text,POST /v1/extract/meta,POST /v1/extract/thumbnail. - GET requests, including job polling and file downloads, are never counted. Requests rejected with 401 or 403 are not counted either.
- The minute window is the UTC clock minute. The day window resets at 00:00 Vietnam time (UTC+7).
- An endpoint or option outside the plan returns
403 forbidden_feature, for example sendingcallback_urlon the Community plan.
Rate limit headers#
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Counted requests allowed per minute. |
X-RateLimit-Remaining | Requests left in the current minute. |
X-RateLimit-Reset | Unix time, in seconds, when the minute window resets. |
X-RateLimit-Limit-Day | Requests per day; absent when the plan has no daily limit. |
X-RateLimit-Remaining-Day | Requests left today; absent when the plan has no daily limit. |
Retry-After | Only on 429 and 503: seconds to wait before retrying. |
Reading the limits in force#
/v1/limitsLimits of the current plan (resolved to numbers, after the lower-only variables) and current usage.
Parameters
AuthorizationheaderrequiredBearer <API key or JWT>.
curl -sS http://localhost:8080/v1/limits \
-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/limits`, { 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/limits", 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/limits");
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/limits");
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());limits keys match the docbuilder section of plans.json; usage shows what was used this minute and today.{
"edition": "community",
"limits": {
"rate_per_minute": 10,
"rate_per_day": 200,
"parallel_jobs": 1,
"max_queued_jobs": 10,
"max_file_mb": 10,
"result_ttl_minutes": 15,
"sync_timeout_seconds": 60,
"job_timeout_seconds": 120,
"async": true,
"callback": false,
"max_script_units": 500,
"max_outputs_per_build": 1
},
"usage": {
"minute": {
"limit": 10,
"used": 3,
"remaining": 7,
"reset_at": "2026-09-21T10:01:00Z"
},
"day": {
"limit": 200,
"used": 41,
"remaining": 159,
"reset_at": "2026-09-21T17:00:00Z"
},
"running_jobs": 0,
"queued_jobs": 0
},
"allowed_endpoints": [
"GET /v1/status",
"GET /v1/formats",
"GET /v1/limits",
"POST /v1/convert",
"GET /v1/jobs/{id}",
"GET /v1/files/{id}",
"POST /v1/build",
"POST /v1/extract/text",
"POST /v1/extract/meta",
"POST /v1/extract/thumbnail"
],
"features": []
}Common errors: 401 unauthorized for a missing or wrong key, 401 token_expired for an expired JWT. GET endpoints are not counted against the limits.
Health check#
/v1/statusService status. No authentication, no secrets.
Parameters
AuthorizationheaderoptionalNot needed. This endpoint requires no authentication.
curl -sS http://localhost:8080/v1/statusconst BASE_URL = "http://localhost:8080";
const res = await fetch(`${BASE_URL}/v1/status`);
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());import requests
BASE_URL = "http://localhost:8080"
r = requests.get(f"{BASE_URL}/v1/status", 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/status");
curl_setopt_array($ch, [
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 var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var res = await http.GetAsync("http://localhost:8080/v1/status");
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());core is the LibreOffice core actually running (the values in the example are illustrative); workers.restarts counts worker restarts since start-up.{
"service": "o3o-docbuilder",
"version": "1.0.0",
"api": "v1",
"time": "2026-09-21T10:00:00Z",
"edition": "community",
"dev_mode": false,
"core": {
"name": "LibreOffice",
"version": "7.4.7.2",
"source": "debian-bookworm"
},
"workers": {
"total": 2,
"idle": 2,
"busy": 0,
"restarts": 0
},
"queue": {
"queued": 0,
"running": 0
},
"uptime_seconds": 3600
}Use this endpoint as the container health check. If the proxy answers 502, the DocBuilder container is not running or not ready yet.
Error format#
Every error on every endpoint has the same JSON body. code is a stable string for programs; message is a human-readable sentence in Vietnamese; detail is an object (possibly empty) that never contains document content; request_id equals the X-O3O-Request-Id header.
{
"error": {
"code": "unsupported_format",
"message": "Không chuyển được từ docx sang xlsx.",
"detail": {
"from": "docx",
"to": "xlsx"
},
"request_id": "req_0123456789abcdef"
}
}| HTTP | Code | When |
|---|---|---|
| 400 | bad_request | Missing parameter, malformed JSON, wrong options type, or neither file nor url. detail.errors = [{path, message}]. |
| 401 | unauthorized | Missing or wrong key. detail.reason is missing, invalid or no_keys_configured. |
| 401 | token_expired | The JWT has expired. |
| 403 | forbidden_feature | Endpoint or option not included in the current plan. detail.feature, detail.edition. |
| 404 | not_found | No job or file with that ID. |
| 410 | gone | The job or file is past its retention time. |
| 413 | file_too_large | Input or output file exceeds max_file_mb. detail.limit_mb. |
| 415 | unsupported_format | Source format not recognised, or the source/target pair is not supported. |
| 422 | script_invalid | The script fails the JSON Schema. detail.errors = [{path, message}], path is a JSON Pointer. |
| 422 | script_too_large | Script units exceed max_script_units. detail.units, detail.limit. |
| 422 | script_error | The script is valid but failed while running. detail.path is a JSON Pointer to the failing element. |
| 422 | template_error | Unbalanced loop tags, or missing fields when missing = "error". detail.fields. |
| 422 | macro_not_allowed | The template sent to POST /v1/template/render contains macros: an OOXML file with a vbaProject.bin part or a macroEnabled content type, or an ODF file with a Basic/ or Scripts/ folder. detail.reason, detail.part. |
| 422 | corrupt_source | LibreOffice cannot open the source file. |
| 422 | password_required | The file is password protected and no password was sent, or the password is wrong. |
| 422 | url_not_allowed | The URL violates the SSRF protection rules. |
| 422 | download_failed | The source URL could not be downloaded. |
| 429 | rate_limited | Rate limit exceeded. detail.window is minute or day; comes with Retry-After. |
| 500 | internal | Unexpected error. |
| 503 | queue_full | The queue is full (max_queued_jobs); comes with Retry-After. |
| 503 | pool_unavailable | No worker is alive. |
| 504 | timeout | Synchronous mode exceeded sync_timeout_seconds, or the job exceeded job_timeout_seconds. The worker is killed and restarted. |
Identifiers#
| Object | Format | Example |
|---|---|---|
| Job | job_ + 24 hex characters | job_5f0c2a9e41b7d3c8a6e1f024 |
| Result file | file_ + 24 hex characters | file_9b3e7d21c4a8f0e65d1b2c37 |
| Request | req_ + 16 hex characters | req_0123456789abcdef |
Coming later#
Not in v1 yet: batch template filling (/v1/template/render-batch), template field inspection (/v1/template/inspect), document comparison (/v1/compare), structure and embedded media extraction (/v1/extract/structure, /v1/extract/media), uploading files ahead of time (POST /v1/files), job cancellation, password-protected PDF output, date and money filters in templates, conditions and images in templates, charts in o3oscript, official SDKs, the /compat/* compatibility layer, a queue that survives restarts, and the O3O 26.8 core instead of the Debian LibreOffice build.
Next steps#
{{name}} fields and table row loops in Word templates.ExtractionText, metadata and thumbnails.Async jobsPolling, downloading results, signed callbacks.Calling the API from your codeShort wrappers in five languages, ready to copy.Practical recipesQuotation PDF, payroll xlsx, mail merge, thumbnails.