Skip to content

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.

POST/v1/build

Build a document from an o3oscript script.

Auth: BearerCommunityEnterprise

Call flow#

  1. Write the script

    Generate JSON that follows o3oscript-v1.schema.json from your data. One script builds exactly one document of one type: text, sheet or slides.
  2. Validate on your side (recommended)

    Check the script with any JSON Schema draft-07 library to catch mistakes early without spending a request.
  3. Send POST /v1/build

    The server validates against the JSON Schema BEFORE queueing (failure: 422 script_invalid), then counts script units against the plan's max_script_units (over: 422 script_too_large).
  4. DocBuilder builds the document

    A free worker creates an empty document, builds the content through UNO in script order, then saves each format listed in save. Failures while building, such as an image that cannot be downloaded, give the code script_error with a JSON Pointer to the failing element.
  5. Collect the result

    Synchronous: the response body is the file. Asynchronous: you get 202 with a job, poll GET /v1/jobs/{id}, then download each file from GET /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

  • scriptobjectrequired
    The o3oscript script.
  • asyncbooleanoptionalDefault: false
    true: return 202 with a job right away. Required when save has more than one entry.
  • callback_urlstring (URL)optional
    Enterprise with the callback feature only. Receives a POST when 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.

JSONScript
{
  "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"
    }
  ]
}
Send the script, receive the 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"
    }
  ]
}
JSON
import { 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());
200Synchronous mode: the response body is the binary file. The block below lists the headers that come with it (Community plan, so the per-day headers are present).
{
  "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.

Contract as docx and PDF/A with a callback (Enterprise)
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"
}
JSON
import 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());
202The job is queued.
{
  "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
}
200The finished job as read from 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:

typeHow script units are counted
textNumber of body elements + total rows of every table (header row included) + total items of every list (all levels).
sheetTotal rows in every data[].values + number of formats entries + number of merges entries, summed over all sheets.
slidesNumber of slides + total number of bullets.
Sample scripttypeUnits
bao-gia.jsontext28
bang-luong.jsonsheet34
gioi-thieu.jsonslides12

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 header or columns.
  • 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 url follow 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.

Validate a script with JSON Schema
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#

HTTPCodeWhen
400bad_requestMalformed JSON, a body that is not an object, or unknown keys in the wrapper.
401unauthorizedMissing or wrong key. detail.reason is missing, invalid or no_keys_configured.
403forbidden_featureSending callback_url on the Community plan.
413file_too_largeInput or output file exceeds max_file_mb. detail.limit_mb.
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.
429rate_limitedRate limit exceeded. detail.window is minute or day; comes with Retry-After.
503queue_fullThe queue is full (max_queued_jobs); comes with Retry-After.
504timeoutSynchronous mode exceeded sync_timeout_seconds, or the job exceeded job_timeout_seconds. The worker is killed and restarted.
422Schema failure: the third 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"
  }
}
422The script exceeds the Community script unit cap.
{
  "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"
  }
}
422A valid script whose image in the eighth 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"
  }
}

Complete examples#