HTML to ODT API
POST /v2/convert/html/to/odt returns an OpenDocument Text file built from the page. The sample seed-swap page (3,041 bytes) came back as a 4,491-byte .odt with outline headings, its table ruled with thin black cell borders and the picture stored under Pictures/.
- Endpoint
- POST /v2/
convert/ html/ to/ odt - Accepts
- html
- Sample timing
- 25 ms median of 5 runsHow we measure
- Request size
- Up to 10 MB on the Free plan or 100 MB on paid plans, all files together
Request and response
Every sample below ran against the API before this page was published. Set PDFCONVERSIONAPI_KEY to your key and run it as is.
curl -sS -X POST \
https://api.pdfconversionapi.com/v2/convert/html/to/odt \
-H "Authorization: Bearer $PDFCONVERSIONAPI_KEY" \
-F File=@seed-swap.html \
-o response.json
# FileData is base64. Save the first file in converted/,
# or print the JSON error.
if jq -e '.Files' response.json > /dev/null; then
mkdir -p converted
name=$(jq -r '.Files[0].FileName' response.json)
jq -r '.Files[0].FileData' response.json \
| base64 --decode > "converted/$name"
else
cat response.json
fiimport base64
import os
import requests
key = os.environ["PDFCONVERSIONAPI_KEY"]
response = requests.post(
"https://api.pdfconversionapi.com/v2/convert/html/to/odt",
headers={"Authorization": f"Bearer {key}"},
files=[("File", open("seed-swap.html", "rb"))],
timeout=180,
)
result = response.json()
if not response.ok:
raise SystemExit(f"{result['Code']}: {result['Message']}")
os.makedirs("converted", exist_ok=True)
for file in result["Files"]:
path = os.path.join("converted", file["FileName"])
with open(path, "wb") as out:
out.write(base64.b64decode(file["FileData"]))
print(path, file["FileSize"], "bytes")
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const form = new FormData();
const file = "seed-swap.html";
form.append("File", new Blob([await readFile(file)]), file);
const url = "https://api.pdfconversionapi.com/v2/convert/html/to/odt";
const key = process.env.PDFCONVERSIONAPI_KEY;
const response = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${key}` },
body: form,
});
const result = await response.json();
if (!response.ok) {
throw new Error(`${result.Code}: ${result.Message}`);
}
await mkdir("converted", { recursive: true });
for (const file of result.Files) {
const path = join("converted", file.FileName);
await writeFile(path, Buffer.from(file.FileData, "base64"));
console.log(path, file.FileSize, "bytes");
}
{
"ConversionCost": 1,
"ConversionTime": 0.025,
"Files": [
{
"FileName": "seed-swap.odt",
"FileExt": "odt",
"FileSize": 4491,
"FileData": "UEsDBAoAAAAAAAAAIQBexjIM…"
}
]
}Response from our test run on 2026-11-03. ConversionTime shows the median of 5 runs. FileData is shortened here; the real response carries the whole file as base64.
The sample, converted
The same file the code sends. Download the input and the output and compare them yourself.

- Input
- seed-swap.html (3.0 KB)
- Output
- seed-swap.odt (4.4 KB)
- Source
- A one-page web page for a fictional community seed library, written for this site: headings, a table, two lists, links, a quote, a style sheet, a script and an inlined picture. (CC0-1.0). How it was made
- Made by
- 2026-11-04, API build
9eeeb0640aba
Parameters
Send these as form fields next to the file. Names are case-insensitive. If you set one of the documented options below that we don't support yet, the request fails with a 400 that names it instead of returning a file made without it.
| Parameter | What it does |
|---|---|
FileNamestring | Name for the .odt without its extension. The sample is returned as seed-swap.odt unless you send one. |
StoreFileboolean, default false | With true the response carries a link in Files[].Url, valid for 3 hours, in place of the base64 data. |
What carries over, and what doesn't
Kept
- A standard ODF 1.3 package: the stored
mimetypeentry first, thencontent.xml,styles.xml,meta.xml, the manifest andPictures/image1.png - Headings as
text:hwith outline levels 1 and 2 - The numbered steps (1, 2, 3) and the bullets as ODF lists, with • at the first level and ◦ for the nested item
- The table: 5 columns of 93.6 pt (468 pt wide), the header row in
table:table-header-rows, header text bold - The 449-byte PNG unchanged, in a 120 × 75 pt frame with the alt text as its description
- Both links as
text:a, themailto:one included styleattributes such as color, font, size, centering, a cell fill and colspan (table:number-columns-spanned)- Title and description from the
<head>in meta.xml
Lost or not yet supported
- The page's style sheet: no Georgia, no green heading. Body text is Aptos 12 pt and headings are #0f4761.
- Table styling from CSS. Every table we sent got the same 0.5 pt black cell borders and 4 pt padding, so the sample's #9bb3a2 borders and #e3efe6 header fill are replaced.
- Numbers as numbers: the table's cells are typed as strings (
office:value-type="string"), 42 and 91% included. - Pictures by URL, which become their alt text, plus scripts and comments.
- Any page size the HTML implies. The .odt is Letter (612 × 792 pt) with 72 pt margins.
Limits
- One HTML file per request.
- Requests up to 10 MB on the Free plan or 100 MB on paid plans, all files and fields together.
- 120 seconds per conversion, then 504.
- Linked pictures and style sheets are not fetched.
Where teams use it
Submissions that must be OpenDocument
Some public bodies and archives accept ODF only. Notices, minutes or policies that live as web pages can be converted at submission time instead of being retyped.
Templates edited in an ODF word processor
Generate a draft as HTML from your data, convert it, and let staff finish it in their usual editor. Headings arrive as real outline-level headings, not bold paragraphs.
Questions
Which ODF version does the API write?
ODF 1.3. office:version="1.3" is set in content.xml, styles.xml and meta.xml, and the package starts with an uncompressed mimetype entry reading application/vnd.oasis.opendocument.text.
Why do my tables have black borders I didn't ask for?
Cell borders and fills in CSS are not read, and the ODT writer gives every table cell a 0.5 pt black border. A cell style="background:#eeeeee" attribute is kept as the cell's background, so inline fills survive even though the grid color doesn't.
Can I set the margins or paper size?
There is no option for it on this route. ConvertAPI doesn't document this pair, and names like MarginHorizontal=2 or PageSize=a4 are ignored: each gave a file byte-identical to one made without options a moment earlier. Between runs only the creation time in meta.xml changes, which can move the size by a byte: the sample and those test files are 4,491 bytes, while an earlier run of the same page gave 4,490.
Why is the .odt half the size of the .docx made from the same page?
The content is the same, but the DOCX carries a 24,595-byte styles.xml and a theme part. The ODT's styles.xml is 4,186 bytes and it has no theme, so the sample is 4,491 bytes against 9,534 for the .docx.
Convert your first HTML file in a minute.