Skip to content

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 toEndpointSee
Change formats: docx to PDF, xlsx to CSV, one slide to an imagePOST /v1/convertFormat conversion
Generate quotations, payroll sheets or slide decks from data you already holdPOST /v1/buildBuilding documents, o3oscript
Fill data into Word templates written by business staffPOST /v1/template/renderTemplate filling (Enterprise)
Pull text for a search index, read metadata, create thumbnailsPOST /v1/extract/text, POST /v1/extract/meta, POST /v1/extract/thumbnailExtraction
Run long jobs or multi-output builds without holding the HTTP connection openGET /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-utils and FastAPI, internal port 8060.
  • Inside is a pool of O3O_DOCBUILDER_WORKERS headless LibreOffice processes (default min(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_JOBS jobs (default 200), after O3O_DOCBUILDER_WORKER_MAX_AGE_MINUTES minutes (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/status in the core field. 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.

KindServer settingRules
API keyO3O_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.

Sign an HS256 JWT and call the API
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.

ItemCommunityEnterprise
Counted requests per minute (rate_per_minute)10clamp(60 * ceil(conns / 50), 60, 3000), where conns is the connection count in the token
Counted requests per day (rate_per_day)200unlimited
Parallel jobs (parallel_jobs)1equal to the worker count (O3O_DOCBUILDER_WORKERS)
Maximum queued jobs (max_queued_jobs)101,000
Maximum input or output file size (max_file_mb)10 MB300 MB
Retention of jobs and result files (result_ttl_minutes)15 minutes1,440 minutes (24 hours)
Synchronous mode time limit (sync_timeout_seconds)60 seconds60 seconds
Job run time limit (job_timeout_seconds)120 seconds600 seconds
Asynchronous mode (async)yesyes
Job completion callback (callback)noyes, when the token has the callback feature
Maximum script units (max_script_units)50020,000
Maximum outputs per build (max_outputs_per_build)15

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 sending callback_url on the Community plan.

Rate limit headers#

HeaderMeaning
X-RateLimit-LimitCounted requests allowed per minute.
X-RateLimit-RemainingRequests left in the current minute.
X-RateLimit-ResetUnix time, in seconds, when the minute window resets.
X-RateLimit-Limit-DayRequests per day; absent when the plan has no daily limit.
X-RateLimit-Remaining-DayRequests left today; absent when the plan has no daily limit.
Retry-AfterOnly on 429 and 503: seconds to wait before retrying.

Reading the limits in force#

GET/v1/limits

Limits of the current plan (resolved to numbers, after the lower-only variables) and current usage.

Auth: BearerCommunityEnterprise

Parameters

  • Authorizationheaderrequired
    Bearer <API key or JWT>.
Read the limits
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());
200Community plan. The 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#

GET/v1/status

Service status. No authentication, no secrets.

Auth: noneCommunityEnterprise

Parameters

  • Authorizationheaderoptional
    Not needed. This endpoint requires no authentication.
Query the status
curl -sS http://localhost:8080/v1/status
const 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());
200The service is up. 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.

415Example: converting across document families.
{
  "error": {
    "code": "unsupported_format",
    "message": "Không chuyển được từ docx sang xlsx.",
    "detail": {
      "from": "docx",
      "to": "xlsx"
    },
    "request_id": "req_0123456789abcdef"
  }
}
HTTPCodeWhen
400bad_requestMissing parameter, malformed JSON, wrong options type, or neither file nor url. detail.errors = [{path, message}].
401unauthorizedMissing or wrong key. detail.reason is missing, invalid or no_keys_configured.
401token_expiredThe JWT has expired.
403forbidden_featureEndpoint or option not included in the current plan. detail.feature, detail.edition.
404not_foundNo job or file with that ID.
410goneThe job or file is past its retention time.
413file_too_largeInput or output file exceeds max_file_mb. detail.limit_mb.
415unsupported_formatSource format not recognised, or the source/target pair is not supported.
422script_invalidThe script fails the JSON Schema. detail.errors = [{path, message}], path is a JSON Pointer.
422script_too_largeScript units exceed max_script_units. detail.units, detail.limit.
422script_errorThe script is valid but failed while running. detail.path is a JSON Pointer to the failing element.
422template_errorUnbalanced loop tags, or missing fields when missing = "error". detail.fields.
422macro_not_allowedThe 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.
422corrupt_sourceLibreOffice cannot open the source file.
422password_requiredThe file is password protected and no password was sent, or the password is wrong.
422url_not_allowedThe URL violates the SSRF protection rules.
422download_failedThe source URL could not be downloaded.
429rate_limitedRate limit exceeded. detail.window is minute or day; comes with Retry-After.
500internalUnexpected error.
503queue_fullThe queue is full (max_queued_jobs); comes with Retry-After.
503pool_unavailableNo worker is alive.
504timeoutSynchronous mode exceeded sync_timeout_seconds, or the job exceeded job_timeout_seconds. The worker is killed and restarted.

Identifiers#

ObjectFormatExample
Jobjob_ + 24 hex charactersjob_5f0c2a9e41b7d3c8a6e1f024
Result filefile_ + 24 hex charactersfile_9b3e7d21c4a8f0e65d1b2c37
Requestreq_ + 16 hex charactersreq_0123456789abcdef

Coming later#

Coming soon

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#