O3O DocBuilder and conversion
Building documents from a script
POST /v1/build takes an o3oscript script and returns a text document, spreadsheet or presentation as docx, odt, xlsx, ods, pptx, odp or PDF; this page covers the call flow, request body, multiple outputs and script units.
On this page
Instead of writing a template and filling it, you describe the document in JSON: headings, paragraphs, tables, sheets, slides. Your code generates the JSON from your data (loops and conditions stay on your side), and DocBuilder builds the document with LibreOffice and saves it in the requested formats. The full syntax is on the o3oscript page.
/v1/buildBuild a document from an o3oscript script.
Call flow#
Write the script
Generate JSON that followso3oscript-v1.schema.jsonfrom your data. One script builds exactly one document of one type:text,sheetorslides.Validate on your side (recommended)
Check the script with any JSON Schema draft-07 library to catch mistakes early without spending a request.Send POST /v1/build
The server validates against the JSON Schema BEFORE queueing (failure:422 script_invalid), then counts script units against the plan'smax_script_units(over:422 script_too_large).DocBuilder builds the document
A free worker creates an empty document, builds the content through UNO in script order, then saves each format listed insave. Failures while building, such as an image that cannot be downloaded, give the codescript_errorwith a JSON Pointer to the failing element.Collect the result
Synchronous: the response body is the file. Asynchronous: you get202with a job, pollGET /v1/jobs/{id}, then download each file fromGET /v1/files/{id}.
Request body#
The body is JSON in one of two shapes: a wrapper {"script": {...}, "async": false}, or the script ITSELF (an object with o3oscript at the root), which implies async = false.
Wrapper object
scriptobjectrequiredThe o3oscript script.asyncbooleanoptionalDefault:falsetrue: return202with a job right away. Required whensavehas more than one entry.callback_urlstring (URL)optionalEnterprise with thecallbackfeature only. Receives aPOSTwhen the job ends.
Minimal example#
The script below builds a one-page invitation and saves it as docx. The examples send the script ITSELF as the body, so the call is synchronous and returns the file directly.
{
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}curl -sS http://localhost:8080/v1/build \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
-o thu-moi.docx -w "HTTP %{http_code}\n" <<'JSON'
{
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}
JSONimport { writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const payload = {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
};
const res = await fetch(`${BASE_URL}/v1/build`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("thu-moi.docx", Buffer.from(await res.arrayBuffer()));import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
payload = {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}
r = requests.post(f"{BASE_URL}/v1/build", headers=HEADERS, json=payload, timeout=90)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("thu-moi.docx", "wb") as fh:
fh.write(r.content)<?php
$payload = json_encode([
'o3oscript' => 1,
'type' => 'text',
'meta' => ['title' => 'Thư mời họp', 'lang' => 'vi-VN'],
'body' => [
['type' => 'heading', 'level' => 1, 'text' => 'Thư mời họp'],
[
'type' => 'paragraph',
'text' => 'Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3.',
],
],
'save' => [['format' => 'docx', 'filename' => 'thu-moi.docx']],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init("http://localhost:8080/v1/build");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload,
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));
}
file_put_contents("thu-moi.docx", $body);using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
var json = """
{
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}
""";
using var res = await http.PostAsync("http://localhost:8080/v1/build",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("thu-moi.docx", await res.Content.ReadAsByteArrayAsync());{
"X-O3O-Request-Id": "req_0123456789abcdef",
"X-O3O-Job-Id": "job_5f0c2a9e41b7d3c8a6e1f024",
"X-O3O-File-Id": "file_9b3e7d21c4a8f0e65d1b2c37",
"Content-Disposition": "attachment; filename=\"thu-moi.docx\"",
"X-RateLimit-Limit": "10",
"X-RateLimit-Remaining": "9",
"X-RateLimit-Reset": "1789984860",
"X-RateLimit-Limit-Day": "200",
"X-RateLimit-Remaining-Day": "187"
}Several outputs#
save accepts up to 5 entries, but the number of outputs per build is capped by the plan's max_outputs_per_build: 1 on Community, 5 on Enterprise. Synchronous mode requires exactly one save entry; use async = true for several outputs. Requests that break these rules are rejected.
curl -sS http://localhost:8080/v1/build \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": true
}
]
},
"async": true,
"callback_url": "https://erp.example.com/o3o/callback"
}
JSONimport requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
payload = {
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": True
}
]
},
"async": True,
"callback_url": "https://erp.example.com/o3o/callback"
}
r = requests.post(f"{BASE_URL}/v1/build", headers=HEADERS, json=payload, timeout=90)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const payload = {
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": true
}
]
},
"async": true,
"callback_url": "https://erp.example.com/o3o/callback"
};
const res = await fetch(`${BASE_URL}/v1/build`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
var json = """
{
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": true
}
]
},
"async": true,
"callback_url": "https://erp.example.com/o3o/callback"
}
""";
using var res = await http.PostAsync("http://localhost:8080/v1/build",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());{
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "build",
"status": "queued",
"created_at": "2026-09-21T10:00:00Z",
"started_at": null,
"finished_at": null,
"duration_ms": null,
"expires_at": null,
"outputs": [],
"error": null,
"callback": null
}GET /v1/jobs/{id}: each outputs entry is one file. Time values only illustrate the structure; they are not measurements.{
"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
}
}Script units#
To keep one oversized script from occupying a worker for too long, each plan caps script units: 500 on Community, 20,000 on Enterprise. Counting depends on the document type:
type | How script units are counted |
|---|---|
text | Number of body elements + total rows of every table (header row included) + total items of every list (all levels). |
sheet | Total rows in every data[].values + number of formats entries + number of merges entries, summed over all sheets. |
slides | Number of slides + total number of bullets. |
| Sample script | type | Units |
|---|---|---|
bao-gia.json | text | 28 |
bang-luong.json | sheet | 34 |
gioi-thieu.json | slides | 12 |
Run-time checks#
The schema cannot express the rules below; the server checks them while building and returns script_error with a JSON Pointer when one is broken.
- Every row of a text table must have as many cells as
headerorcolumns. - Lists nest at most 3 levels deep.
- Sheet names must be unique.
- Base64 images must decode and be no larger than 10 MB.
- Images given by
urlfollow the same SSRF rules as every other URL DocBuilder downloads.
Validating scripts on your side#
The schema is JSON Schema draft-07 with $id https://office.o3o.vn/api/schema/o3oscript-v1.schema.json. The file ships in the DocBuilder image at /app/o3oscript-v1.schema.json; copy it out with docker cp, or with docker compose cp o3o-docbuilder:/app/o3oscript-v1.schema.json . from the compose directory.
import json
from jsonschema import Draft7Validator # pip install jsonschema
with open("o3oscript-v1.schema.json", encoding="utf-8") as f:
validator = Draft7Validator(json.load(f))
with open("bao-gia.json", encoding="utf-8") as f:
script = json.load(f)
errors = sorted(validator.iter_errors(script), key=lambda e: list(e.absolute_path))
for e in errors:
print("/" + "/".join(str(p) for p in e.absolute_path), e.message)
print("valid" if not errors else f"{len(errors)} errors")import { readFile } from "node:fs/promises";
import Ajv from "ajv"; // npm install ajv
const schema = JSON.parse(await readFile("o3oscript-v1.schema.json", "utf8"));
const script = JSON.parse(await readFile("bao-gia.json", "utf8"));
const validate = new Ajv({ allErrors: true, strict: false, validateFormats: false }).compile(schema);
if (validate(script)) console.log("valid");
else for (const e of validate.errors) console.log(e.instancePath || "/", e.message);Common errors#
| HTTP | Code | When |
|---|---|---|
| 400 | bad_request | Malformed JSON, a body that is not an object, or unknown keys in the wrapper. |
| 401 | unauthorized | Missing or wrong key. detail.reason is missing, invalid or no_keys_configured. |
| 403 | forbidden_feature | Sending callback_url on the Community plan. |
| 413 | file_too_large | Input or output file exceeds max_file_mb. detail.limit_mb. |
| 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. |
| 429 | rate_limited | Rate limit exceeded. detail.window is minute or day; comes with Retry-After. |
| 503 | queue_full | The queue is full (max_queued_jobs); comes with Retry-After. |
| 504 | timeout | Synchronous mode exceeded sync_timeout_seconds, or the job exceeded job_timeout_seconds. The worker is killed and restarted. |
body element has level 7.{
"error": {
"code": "script_invalid",
"message": "Kịch bản không hợp lệ.",
"detail": {
"errors": [
{
"path": "/body/2/level",
"message": "7 lớn hơn giá trị tối đa 6"
}
]
},
"request_id": "req_0123456789abcdef"
}
}{
"error": {
"code": "script_too_large",
"message": "Kịch bản vượt số đơn vị cho phép của gói.",
"detail": {
"units": 812,
"limit": 500
},
"request_id": "req_0123456789abcdef"
}
}body element could not be downloaded.{
"error": {
"code": "script_error",
"message": "Không tải được ảnh của kịch bản.",
"detail": {
"path": "/body/7/src/url"
},
"request_id": "req_0123456789abcdef"
}
}