O3O DocBuilder and conversion
Format conversion
POST /v1/convert turns a file into another format of the same family (text, spreadsheet, presentation), including PDF, PDF/A and a PNG or JPG of one page; synchronous or asynchronous, from an upload or a URL.
On this page
Send a source file, get the target file back. The source arrives in one of two ways: an upload with multipart/form-data (field file), or application/json with a url that DocBuilder downloads itself. Conversion stays within one document family; crossing families, for example docx to xlsx, returns 415 unsupported_format.
/v1/convertConvert a file. Synchronous mode returns the file; asynchronous mode returns a job.
Parameters#
multipart/form-data body
filefilerequiredSource file.tostringrequiredTarget format: docx, odt, rtf, txt, html, pdf, xlsx, ods, csv, pptx, odp, png, jpg.fromstringoptionalSource format: docx, doc, odt, rtf, txt, html, xlsx, xls, ods, csv, pptx, ppt, odp. When absent, the file name extension is used.filenamestringoptionalDefault:source name with the new extensionResult file name, up to 255 characters.optionsstring (JSON)optionalConversion options, sent as a JSON STRING (see the table below).asyncbooleanoptionalDefault:falsetrue: return202with a job right away instead of waiting for the file.callback_urlstring (URL)optionalEnterprise with thecallbackfeature only. Receives aPOSTwhen the job ends.
application/json body
urlstring (URL)requiredURL of the source file, up to 2048 characters, subject to the SSRF rules.tostringrequiredTarget format: docx, odt, rtf, txt, html, pdf, xlsx, ods, csv, pptx, odp, png, jpg.fromstringoptionalSource format: docx, doc, odt, rtf, txt, html, xlsx, xls, ods, csv, pptx, ppt, odp. When absent, the file name extension is used.filenamestringoptionalDefault:source name with the new extensionResult file name, up to 255 characters.optionsobjectoptionalConversion options (see the table below).asyncbooleanoptionalDefault:falsetrue: return202with a job right away instead of waiting for the file.callback_urlstring (URL)optionalEnterprise with thecallbackfeature only. Receives aPOSTwhen the job ends.
The options object
passwordstringoptionalPassword to OPEN the source file. Missing or wrong:422 password_required.pdf.pdfabooleanoptionalDefault:falseExport PDF/A-2b for long-term archiving.pdf.page_rangestringoptionalDefault:all pagesPage range such as1-3,5. Pattern:^[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$.pdf.image_qualityinteger 1–100optionalDefault:90Quality of images inside the PDF.image.pageinteger ≥ 1optionalDefault:1Page rendered when the target ispngorjpg.image.widthinteger 16–4000optionalDefault:1240Image width in pixels; the height follows the page ratio.csv.delimiterstring, 1 characteroptionalDefault:,Column separator.csv.encodingutf-8 | windows-1258 | windows-1252optionalDefault:utf-8CSV character encoding.csv.sheetinteger ≥ 1optionalDefault:1Sheet exported when the target iscsv.
Synchronous conversion#
By default (async = false) DocBuilder holds the connection until the work is done and returns the result file with status 200. Beyond sync_timeout_seconds (60 seconds on both plans) it returns 504 timeout; use asynchronous mode for large or long files. The file ID in the X-O3O-File-Id header can be downloaded again from GET /v1/files/{id} until it expires.
curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@bao-cao.docx" \
-F "to=pdf" \
-o bao-cao.pdf -w "HTTP %{http_code}\n"import { readFile, writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const form = new FormData();
form.append("file", new Blob([await readFile("bao-cao.docx")]), "bao-cao.docx");
form.append("to", "pdf");
const res = await fetch(`${BASE_URL}/v1/convert`, {
method: "POST",
headers: HEADERS,
body: form,
signal: AbortSignal.timeout(90_000),
});
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"}
with open("bao-cao.docx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/convert",
headers=HEADERS,
files={"file": ("bao-cao.docx", f)},
data={
"to": "pdf",
},
timeout=90,
)
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/convert");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('bao-cao.docx'),
'to' => 'pdf',
],
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("bao-cao.pdf", $body);using System.Net.Http.Headers;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(await File.ReadAllBytesAsync("bao-cao.docx")), "file", "bao-cao.docx");
form.Add(new StringContent("pdf"), "to");
using var res = await http.PostAsync("http://localhost:8080/v1/convert", form);
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("bao-cao.pdf", 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=\"bao-cao.pdf\"",
"X-RateLimit-Limit": "10",
"X-RateLimit-Remaining": "9",
"X-RateLimit-Reset": "1789984860",
"X-RateLimit-Limit-Day": "200",
"X-RateLimit-Remaining-Day": "187"
}Source from a URL#
Send application/json with url. DocBuilder downloads the file under the SSRF rules: http and https only, no user:pass@ part, at most 2048 characters; every address the host name resolves to must be public, and DocBuilder connects straight to the checked IP address without resolving again (DNS rebinding protection); up to 3 redirects, each hop checked again; only status 200 counts as success; the download time limit is O3O_FETCH_TIMEOUT_SECONDS (30 seconds by default).
curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/files/bao-cao.docx", "to": "pdf", "options": {"pdf": {"pdfa": true}}}' \
-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 payload = {
"url": "https://example.com/files/bao-cao.docx",
"to": "pdf",
"options": {
"pdf": {
"pdfa": true
}
}
};
const res = await fetch(`${BASE_URL}/v1/convert`, {
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("bao-cao.pdf", Buffer.from(await res.arrayBuffer()));import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
payload = {
"url": "https://example.com/files/bao-cao.docx",
"to": "pdf",
"options": {
"pdf": {
"pdfa": True
}
}
}
r = requests.post(f"{BASE_URL}/v1/convert", headers=HEADERS, json=payload, timeout=90)
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
$payload = json_encode([
'url' => 'https://example.com/files/bao-cao.docx',
'to' => 'pdf',
'options' => ['pdf' => ['pdfa' => true]],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init("http://localhost:8080/v1/convert");
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("bao-cao.pdf", $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 = """
{
"url": "https://example.com/files/bao-cao.docx",
"to": "pdf",
"options": {
"pdf": {
"pdfa": true
}
}
}
""";
using var res = await http.PostAsync("http://localhost:8080/v1/convert",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("bao-cao.pdf", await res.Content.ReadAsByteArrayAsync());Asynchronous conversion#
Set async = true to get 202 right away with a job object and the header Location: /v1/jobs/{id}. Then poll and download as described in Async jobs. Both plans can use this mode; callback_url, however, needs Enterprise with the callback feature, and Community receives 403 forbidden_feature for it.
curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@bao-cao.docx" \
-F "to=pdf" \
-F "async=true"import { readFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const form = new FormData();
form.append("file", new Blob([await readFile("bao-cao.docx")]), "bao-cao.docx");
form.append("to", "pdf");
form.append("async", "true");
const res = await fetch(`${BASE_URL}/v1/convert`, {
method: "POST",
headers: HEADERS,
body: form,
signal: AbortSignal.timeout(90_000),
});
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"}
with open("bao-cao.docx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/convert",
headers=HEADERS,
files={"file": ("bao-cao.docx", f)},
data={
"to": "pdf",
"async": "true",
},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())<?php
$ch = curl_init("http://localhost:8080/v1/convert");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('bao-cao.docx'),
'to' => 'pdf',
'async' => 'true',
],
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));
}
print_r(json_decode($body, true));using System.Net.Http.Headers;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(await File.ReadAllBytesAsync("bao-cao.docx")), "file", "bao-cao.docx");
form.Add(new StringContent("pdf"), "to");
form.Add(new StringContent("true"), "async");
using var res = await http.PostAsync("http://localhost:8080/v1/convert", form);
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());Location header points to /v1/jobs/{id}.{
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "convert",
"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
}PDF options#
pdf.pdfa = trueexports PDF/A-2b, suited to long-term records.pdf.page_rangeselects pages, for example1-3,5means pages 1 to 3 and page 5.pdf.image_qualityfrom 1 to 100 (default 90): lower values give smaller files and softer images.
curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@bao-cao.docx" \
-F "to=pdf" \
--form-string 'options={"pdf":{"pdfa":true,"page_range":"1-3,5","image_quality":80}}' \
-o bao-cao-trich.pdf -w "HTTP %{http_code}\n"import json
import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
with open("bao-cao.docx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/convert",
headers=HEADERS,
files={"file": ("bao-cao.docx", f)},
data={
"to": "pdf",
"options": json.dumps({"pdf": {"pdfa": True, "page_range": "1-3,5", "image_quality": 80}}, ensure_ascii=False),
},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("bao-cao-trich.pdf", "wb") as fh:
fh.write(r.content)import { readFile, writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const form = new FormData();
form.append("file", new Blob([await readFile("bao-cao.docx")]), "bao-cao.docx");
form.append("to", "pdf");
form.append("options", JSON.stringify({"pdf": {"pdfa": true, "page_range": "1-3,5", "image_quality": 80}}));
const res = await fetch(`${BASE_URL}/v1/convert`, {
method: "POST",
headers: HEADERS,
body: form,
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("bao-cao-trich.pdf", Buffer.from(await res.arrayBuffer()));Password-protected PDF output. Today options.password only opens a password-protected source file.
Rendering one page as an image#
A png or jpg target renders exactly one page per call, chosen with options.image.page (page 1 by default), options.image.width pixels wide (16 to 4000, default 1240), with the height following the page ratio. The image goes through an intermediate PDF and pdftoppm, so any page of a text document, spreadsheet or presentation can be chosen. For thumbnails, use POST /v1/extract/thumbnail.
curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@gioi-thieu.pptx" \
-F "to=png" \
--form-string 'options={"image":{"page":2,"width":1600}}' \
-o trang-2.png -w "HTTP %{http_code}\n"import json
import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
with open("gioi-thieu.pptx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/convert",
headers=HEADERS,
files={"file": ("gioi-thieu.pptx", f)},
data={
"to": "png",
"options": json.dumps({"image": {"page": 2, "width": 1600}}, ensure_ascii=False),
},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("trang-2.png", "wb") as fh:
fh.write(r.content)import { readFile, writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const form = new FormData();
form.append("file", new Blob([await readFile("gioi-thieu.pptx")]), "gioi-thieu.pptx");
form.append("to", "png");
form.append("options", JSON.stringify({"image": {"page": 2, "width": 1600}}));
const res = await fetch(`${BASE_URL}/v1/convert`, {
method: "POST",
headers: HEADERS,
body: form,
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("trang-2.png", Buffer.from(await res.arrayBuffer()));CSV#
The options.csv options apply when the source or the target is csv: delimiter is one character (comma by default), encoding is utf-8, windows-1258 or windows-1252, and sheet picks the sheet exported to csv (sheet 1 by default). CSV files saved by Excel on a machine set to the Vietnamese region may use semicolons and the windows-1258 encoding; set both options to match.
curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@bang-luong.xlsx" \
-F "to=csv" \
--form-string 'options={"csv":{"delimiter":";","sheet":2}}' \
-o theo-phong-ban.csv -w "HTTP %{http_code}\n"import json
import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
with open("bang-luong.xlsx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/convert",
headers=HEADERS,
files={"file": ("bang-luong.xlsx", f)},
data={
"to": "csv",
"options": json.dumps({"csv": {"delimiter": ";", "sheet": 2}}, ensure_ascii=False),
},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("theo-phong-ban.csv", "wb") as fh:
fh.write(r.content)curl -sS http://localhost:8080/v1/convert \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@du-lieu.csv" \
-F "to=xlsx" \
--form-string 'options={"csv":{"delimiter":";","encoding":"windows-1258"}}' \
-o du-lieu.xlsx -w "HTTP %{http_code}\n"import json
import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
with open("du-lieu.csv", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/convert",
headers=HEADERS,
files={"file": ("du-lieu.csv", f)},
data={
"to": "xlsx",
"options": json.dumps({"csv": {"delimiter": ";", "encoding": "windows-1258"}}, ensure_ascii=False),
},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("du-lieu.xlsx", "wb") as fh:
fh.write(r.content)Supported formats#
| Family | Source | Target |
|---|---|---|
text | docx, doc, odt, rtf, txt, html | docx, odt, rtf, txt, html, pdf, png, jpg |
sheet | xlsx, xls, ods, csv | xlsx, ods, csv, html, pdf, png, jpg |
slide | pptx, ppt, odp | pptx, odp, pdf, png, jpg |
The full matrix, how DocBuilder detects the source format and the list of installed fonts: see Format matrix.
Plans and limits#
Both plans can call this endpoint. The relevant limits:
| Item | Community | Enterprise |
|---|---|---|
Maximum input or output file size (max_file_mb) | 10 MB | 300 MB |
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 |
Common errors#
| 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. |
| 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. |
| 415 | unsupported_format | Unknown source format, or a cross-family conversion (for example docx to xlsx). |
| 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. |
| 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. |
options.password was not sent.{
"error": {
"code": "password_required",
"message": "Tệp có mật khẩu. Hãy gửi mật khẩu mở tệp trong options.password.",
"detail": {},
"request_id": "req_0123456789abcdef"
}
}Retry-After.{
"error": {
"code": "rate_limited",
"message": "Đã vượt hạn mức yêu cầu trong phút này.",
"detail": {
"window": "minute"
},
"request_id": "req_0123456789abcdef"
}
}