Most PDFs an application produces are documents with a fixed layout and variable data: an invoice, a receipt, a statement, a certificate. You already have a good layout language for that. HTML and CSS handle tables, typography and images, your team can read them, and you can preview a template in a browser in a second. The missing piece is the renderer that turns markup into pages, and running headless Chrome yourself means a large binary, fonts on the server, memory spikes and zombie processes.
HTML to PDF (htmltopdf) does the rendering for you. You post a string of HTML; it renders it in a headless browser and hands back a link to the finished file. This lesson builds an invoice renderer around it and covers the parts that decide whether the output looks right: page size, margins, print CSS, page breaks and fonts.
The request and what comes back
It is a POST with a JSON body. One parameter is required, html, the full document as a string, up to 500,000 characters. Four margin parameters (marginTop, marginRight, marginBottom, marginLeft, in inches) and landscape are available on paid plans; on the Free plan you get the defaults, 0.4 inches all round and portrait. Each call costs 20 credits.
curl -X POST "https://api.apiverve.com/v1/htmltopdf" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<html><head><style>body{font-family:Arial,sans-serif} .total{font-weight:bold}</style></head><body><h1>Invoice INV-1042</h1><p>Due in 30 days.</p><p class=\"total\">Total: $120.00</p></body></html>"}'The response is a link and its expiry, not the PDF bytes. Compare expires with the current time.
The response does not contain the PDF. It contains a pointer to it:
| Field | What it is |
|---|---|
downloadURL | A signed link to the generated file on a storage host |
expires | When that link stops working, as a Unix timestamp in milliseconds |
pdfName | The generated file name, a random identifier plus .pdf |
marginTop and the other margins | The margins applied, as strings such as "0.4in" |
landscape | Whether landscape orientation was applied |
Two details matter. The margins come back as strings with a unit, not numbers, so do not do arithmetic on them without parsing. And expires is in milliseconds even though the reference just calls it a Unix timestamp. When we called it, the link was valid for 48 hours. Treat that as an observation, not a contract: read the field every time.
Escaping and sending the HTML
The definition asks for "properly escaped" HTML. That only means the HTML must be a valid JSON string. If you build the body with JSON.stringify in Node or json= in Python, quotes, newlines and backslashes are escaped for you. The bug appears when someone builds the JSON by hand with template strings: one double quote in a customer name and the request body is invalid JSON.
The other escaping problem is yours, not the API's. Any customer data you interpolate into the template has to be HTML-escaped, or a company called <script> or Smith & Sons breaks your layout. The renderer is a real browser; whatever markup you send, it renders.
Here is the renderer. It is ESM, so set "type": "module" in package.json or name the file .mjs.
import { writeFile } from 'node:fs/promises';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
export function invoiceHtml(inv) {
const rows = inv.lines.map((l) => `<tr><td>${esc(l.item)}</td><td class="num">${l.qty}</td><td class="num">$${l.amount.toFixed(2)}</td></tr>`).join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 11pt; color: #222; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: 6px 4px; border-bottom: 1px solid #ddd; }
.num { text-align: right; }
tr { break-inside: avoid; }
thead { display: table-header-group; }
.terms { break-before: page; }
</style></head><body>
<h1>Invoice ${esc(inv.number)}</h1>
<p>Bill to: ${esc(inv.customer)}</p>
<table><thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Amount</th></tr></thead><tbody>${rows}</tbody></table>
<section class="terms"><h2>Terms</h2><p>Payment due within 30 days.</p></section>
</body></html>`;
}
export async function renderPdf(html) {
const res = await fetch('https://api.apiverve.com/v1/htmltopdf', {
method: 'POST',
headers: { 'x-api-key': process.env.APIVERVE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ html }),
signal: AbortSignal.timeout(30000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') throw new Error(body?.error || `HTTP ${res.status}`);
return body.data;
}
export async function savePdf(data, path) {
if (Date.now() >= data.expires) throw new Error(`Link for ${data.pdfName} has expired`);
const res = await fetch(data.downloadURL, { signal: AbortSignal.timeout(30000) }); // no API key here
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
if (bytes.subarray(0, 5).toString() !== '%PDF-') throw new Error('Downloaded file is not a PDF');
await writeFile(path, bytes);
return path;
}
const inv = { number: 'INV-1042', customer: 'Smith & Sons', lines: [{ item: 'Hosting', qty: 1, amount: 120 }] };
const data = await renderPdf(invoiceHtml(inv));
console.log(await savePdf(data, `${inv.number}.pdf`));import html as htmllib
import os
import time
import requests
def invoice_html(inv):
rows = "".join(
f'<tr><td>{htmllib.escape(l["item"])}</td><td class="num">{l["qty"]}</td>'
f'<td class="num">${l["amount"]:.2f}</td></tr>'
for l in inv["lines"]
)
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
body {{ font-family: Arial, sans-serif; font-size: 11pt; color: #222; }}
table {{ width: 100%; border-collapse: collapse; }}
td, th {{ padding: 6px 4px; border-bottom: 1px solid #ddd; }}
.num {{ text-align: right; }}
tr {{ break-inside: avoid; }}
thead {{ display: table-header-group; }}
.terms {{ break-before: page; }}
</style></head><body>
<h1>Invoice {htmllib.escape(inv["number"])}</h1>
<p>Bill to: {htmllib.escape(inv["customer"])}</p>
<table><thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Amount</th></tr></thead>
<tbody>{rows}</tbody></table>
<section class="terms"><h2>Terms</h2><p>Payment due within 30 days.</p></section>
</body></html>"""
def render_pdf(markup):
res = requests.post(
"https://api.apiverve.com/v1/htmltopdf",
json={"html": markup},
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=30,
)
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"]
def save_pdf(data, path):
if time.time() * 1000 >= data["expires"]:
raise RuntimeError(f"Link for {data['pdfName']} has expired")
pdf = requests.get(data["downloadURL"], timeout=30) # no API key here
pdf.raise_for_status()
if not pdf.content.startswith(b"%PDF-"):
raise RuntimeError("Downloaded file is not a PDF")
with open(path, "wb") as f:
f.write(pdf.content)
return path
if __name__ == "__main__":
inv = {"number": "INV-1042", "customer": "Smith & Sons", "lines": [{"item": "Hosting", "qty": 1, "amount": 120}]}
print(save_pdf(render_pdf(invoice_html(inv)), f"{inv['number']}.pdf"))A few decisions in that code are worth defending. The timeout is 30 seconds because rendering is real work, and the documentation recommends that ceiling for rendering endpoints. The download is a separate plain GET with no API key: the storage host is not APIVerve, the URL carries its own signature, and your key should never travel anywhere it is not needed. The %PDF- check costs nothing and catches the case where a proxy or an expired link hands you an error page that you would otherwise save with a .pdf extension.
A customer's company name is Acme "Best" Widgets. Your Node code builds the request body as a template string: `{"html": "${markup}"}`. What happens?
Answer the check above to continue.
Fonts and images
The renderer fetches remote resources. In our tests a Google Fonts <link> in the <head> loaded and the font was embedded in the PDF, and an <img> with an absolute https URL was drawn. Relative URLs like /img/logo.png cannot work, because the HTML has no origin: always use absolute URLs, or embed small assets as data: URIs so the render does not depend on another server being up.
System fonts are a different story. When we asked for Arial, Georgia and Courier New, the PDF embedded Liberation Sans, Liberation Serif and Liberation Mono. Those are metric-compatible substitutes, so line breaks land in the same places, but the letterforms are not identical. If brand typography matters, load the exact font as a web font. The same applies to scripts outside Latin: a test line containing Japanese text embedded only the three Liberation fonts, none of which has Japanese glyphs. If your documents may contain CJK names, Arabic or emoji, ship a web font that covers them, and check a rendered sample.
Each remote resource is a network fetch during rendering. A template that pulls three font weights and a 4 MB hero image renders slower and fails more often than one that uses one weight and a compressed logo. Keep print templates lean.
Storing what you generate
Save the file to storage you control the moment the call returns, then treat your copy as the source of truth. A sensible pattern for invoices:
- Render and download immediately, in the same job.
- Store the bytes in object storage under a key you choose, such as
invoices/2026/INV-1042.pdf, not underpdfName, which is a random identifier you cannot look up later. - Record the storage key and a hash of the bytes on the invoice row.
- Serve customers your own URL, behind your own auth check.
Invoices are legal records in many places, so regenerating "the same" invoice later from current data is a trap: the customer's address, your tax rate or the template may have changed. Generate once, store the bytes, and serve that.
If the download step fails, you have up to the expires time to retry it without paying for another render. Retry the download on network errors; only re-render if the link has expired. A queue job that tracks the two steps separately gets this right for free.
Where this goes next
HTML is the right input when you own the template. Often you do not: you need a PDF of a web page someone else built, or of documentation your team writes in Markdown. The next lesson covers both, at /academy/generate-pdfs/02-websites-and-markdown-to-pdf. The full parameter reference for this API is at docs.apiverve.com.