Sooner or later a product that handles documents receives a .docx. A contract template edited in Word, a price list exported from Excel, a sales deck someone wants attached to a proposal. Browsers cannot render these, so none of the HTML tools from the first two lessons help. Converting them yourself means installing LibreOffice on a server, keeping its fonts current and stopping a hung conversion from eating a worker.
Office to PDF (officetopdf) runs that conversion for you, with LibreOffice. It accepts Word (doc, docx), Excel (xls, xlsx), PowerPoint (ppt, pptx), OpenDocument (odt, ods, odp), RTF, plain text and CSV, up to 10 MB per document. Each call converts one document and costs 30 credits.
01Two ways to send the document
The one input parameter is document, and it takes either form:
- A public URL to the file, as a JSON body:
{"document": "https://..."}. The definition also acceptsurlas an alias for the same parameter. - The file itself, as a
multipart/form-dataupload with the field namedocument.
Start with the URL form, because it is the one you can try from this page:
curl -X POST "https://api.apiverve.com/v1/officetopdf" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document":"https://raw.githubusercontent.com/apache/poi/trunk/test-data/document/SampleDoc.docx"}'inputFormat is detected from the file's contents; pageCount tells you how many pages the PDF has.
That sample Word document converts to a two-page PDF. The response gives you more to work with than the HTML renderers did:
| Field | What it is |
|---|---|
pageCount | Pages in the resulting PDF |
inputFormat | The format the document was converted as, detected from its contents |
inputName | The input file name, such as SampleDoc.docx |
size | Size of the PDF in bytes |
pageRanges | The pages converted, or null for the whole document |
landscape | Whether landscape orientation was applied |
downloadURL, expires, pdfName | The signed link (valid for 2 days), its expiry in milliseconds, and the file name |
The same call with an Excel workbook (SampleSS.xlsx from the same test-data repository) returned inputFormat: "xlsx" and two pages, and a PowerPoint deck (SampleShow.pptx) returned inputFormat: "pptx" and two pages, one per slide.
Which input form to use
The URL form is convenient when the file already sits somewhere public, but "public" is the catch. The service fetches the URL from the internet without your credentials, so a link to a private bucket or an intranet share fails. Making a contract publicly readable so a converter can fetch it is the wrong trade.
There is a middle path when files live in your object storage: generate a short-lived signed URL (a few minutes is plenty) and send that. The file stays private, the link is useless shortly afterwards, and your server never has to hold the bytes.
The upload form is right when the bytes are already in your process, typically because a user just uploaded them to you. Two limits apply: 10 MB per document, and uploads on the Free plan are limited to 400 KB. A typical text-heavy DOCX fits under 400 KB; a deck with photos usually does not. If your own upload form accepts larger files than your plan forwards, you will see that as a 400 naming the size ceiling, so check sizes before calling and give the user a clear message instead.
Your app stores customer contracts in a private storage bucket. What is the best way to convert one with Office to PDF?
02A conversion endpoint for user uploads
Here is the pattern most apps need: a user uploads a document to your server, your server converts it, saves the PDF and returns its page count. The Node version is an Express 4 server using multer to receive the upload in memory, then forwarding it with the FormData and Blob globals built into Node 18. It is ESM.
npm init -y && npm pkg set type=module
npm install express@4 multer@1
export APIVERVE_API_KEY=YOUR_API_KEYimport express from 'express';
import multer from 'multer';
import { writeFile, mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const ALLOWED = /\.(docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv)$/i;
const MAX_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 400 * 1024); // raise to 10 MB on a paid plan
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_BYTES } });
const app = express();
async function officeToPdf(file) {
const form = new FormData();
form.append('document', new Blob([file.buffer]), file.originalname);
const res = await fetch('https://api.apiverve.com/v1/officetopdf', {
method: 'POST',
headers: { 'x-api-key': process.env.APIVERVE_API_KEY }, // no Content-Type: fetch sets the boundary
body: form,
signal: AbortSignal.timeout(60000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') {
const err = new Error(body?.error || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return body.data;
}
app.post('/convert', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Attach a file in the "file" field' });
if (!ALLOWED.test(req.file.originalname)) return res.status(415).json({ error: 'Unsupported file type' });
try {
const data = await officeToPdf(req.file);
const pdf = Buffer.from(await (await fetch(data.downloadURL)).arrayBuffer());
await mkdir('converted', { recursive: true });
const id = randomUUID();
await writeFile(`converted/${id}.pdf`, pdf);
res.json({ id, pages: data.pageCount, detectedFormat: data.inputFormat, bytes: data.size });
} catch (e) {
res.status(e.status === 400 ? 422 : 502).json({ error: e.message });
}
});
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: `File is larger than ${MAX_BYTES} bytes` });
next(err);
});
app.listen(3000, () => console.log('http://localhost:3000'));import os
import sys
import requests
MAX_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", 400 * 1024))
def office_to_pdf(path):
if os.path.getsize(path) > MAX_BYTES:
raise ValueError(f"{path} is larger than {MAX_BYTES} bytes")
with open(path, "rb") as f:
res = requests.post(
"https://api.apiverve.com/v1/officetopdf",
files={"document": (os.path.basename(path), f)},
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=60,
)
body = res.json()
if res.status_code != 200 or body.get("status") != "ok":
raise RuntimeError(body.get("error") or f"HTTP {res.status_code}")
return body["data"]
if __name__ == "__main__":
data = office_to_pdf(sys.argv[1])
pdf = requests.get(data["downloadURL"], timeout=60).content
out = os.path.splitext(sys.argv[1])[0] + ".pdf"
with open(out, "wb") as f:
f.write(pdf)
print(out, data["pageCount"], "pages, converted as", data["inputFormat"])The most common bug in this code is one it avoids: setting Content-Type: multipart/form-data by hand. The header has to carry the multipart boundary, which only the HTTP client knows, so a hand-written header produces a body the server cannot parse. Let fetch and requests set it.
The Express handler maps API errors to its own status codes on purpose. A 400 from the converter means the document was the problem, so the user gets a 422 they can act on; anything else is an upstream failure, a 502. Do not pass the converter's status straight through: a 401 from APIVerve means your key is wrong, and telling a user their upload is "unauthorized" sends them chasing the wrong problem.
03Where conversions go wrong
The extension lies
Files arrive misnamed all the time: a .doc that is really RTF, an .xls that is really an xlsx workbook. The service checks contents, not just the name, and converts the file as what it really is, reporting that in inputFormat. Log it when inputFormat disagrees with the extension; it is harmless for conversion but tells you something about where your files come from.
A file that is not an office document at all is rejected with a 400. When we pointed the URL form at an ordinary web page, the error read: "could not tell the document type; upload it with its extension". Your allow-list of extensions is a first filter for users, not a security boundary, which is why the server above still handles that 400.
Fonts and layout
LibreOffice lays the document out again; it does not replay Word's rendering. Fonts that are not installed on the converter are replaced by metric-compatible substitutes, so line breaks usually land where they did, but a document set in a proprietary brand font will come out in a lookalike. For templates you control, use common fonts or check one converted sample against the original. Complex Word layouts, such as floating text boxes anchored to paragraphs, are the usual place for differences to show up, so include one in your sample check if your templates use them.
Spreadsheets have no pages until you print them
A worksheet is a grid, not a page, so its PDF depends on the workbook's print settings: print area, scaling, orientation and page breaks. A wide sheet with default settings prints its columns across several pages, so your one-screen table becomes a stack of fragments. Fix it in the template: set a print area, choose "fit to one page wide", set landscape. Paid plans can also pass landscape: true and pageRanges (for example "1-2") on the call. pageCount is your alarm here: if a one-page price list comes back as nine pages, the print setup is wrong, and you should flag it instead of emailing it to a customer.
Things that are not supported
Password-protected documents cannot be converted; ask for an unprotected copy. Macros do not run, so a workbook whose numbers are filled in by a macro converts with whatever values it last saved. Embedded video is not supported. Encryption and macros are both reasons to reject early with a useful message rather than send the file and decode a failure.
A one-page Excel price list converts to a 9-page PDF with columns split across pages. What is the most effective fix?
04Using pageCount as a quality gate
Every conversion tells you how many pages came out. That is cheap, deterministic and surprisingly useful:
- A contract template that has always been 4 pages coming back as 5 means someone edited the template and pushed the signature block onto a new page.
- A 30-page report that comes back as 2 pages means the wrong file, or an empty export, reached the converter.
- A spreadsheet that explodes into dozens of pages has a print-area problem.
Store the expected page count per template and alert on deviations. It catches most layout regressions without anyone opening a PDF.
05Next
You can now produce PDFs from HTML, web pages, Markdown and office files. Real workflows rarely stop at one file: they bundle, number and read them. That is the last lesson, /academy/generate-pdfs/merge-number-and-extract-pdfs. The full reference for this API is on its marketplace page.