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.
01The 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.
02Escaping 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?
03Page size, margins and orientation
The paper size is not a parameter. Every PDF we generated came out as A4 portrait, 595.92 by 842.88 points. We also tried @page { size: letter } and @page { size: 8.5in 11in } in the template, and the sheet stayed A4 both times. If your customers expect US Letter, that is worth knowing before you design a layout that relies on 11 inches of height: build to the smaller dimension of each (A4's width and Letter's height) and the document reads fine on either when printed.
Margins and orientation are parameters on paid plans: send marginTop: 0.75 and so on in inches, and landscape: true for wide tables. On the Free plan the 0.4-inch default applies. If you need more breathing room without a paid plan, pad the content itself with body { padding: ... }. That is not the same as a page margin, because padding applies to the document box, not to every page, so on multi-page documents only the first page's top and the last page's bottom get it. For single-page invoices and receipts it is indistinguishable.
04Print CSS that actually works
The renderer prints the page the way Chrome prints it, so CSS written for screens often needs a print pass. The rules that matter most:
- Forced breaks.
break-before: pageon an element starts it on a new page. The invoice above uses it to put the terms on page two, and the call returns a two-page PDF. The olderpage-break-before: alwaysstill works too. - Unbreakable blocks.
break-inside: avoidon a table row, a signature block or a card keeps it from being split across two pages. Without it, a row can be cut through the middle of its text. - Repeating table headers.
thead { display: table-header-group; }repeats the header row at the top of each page when a long table spills over. - Media queries. A
@media printblock applies here. If your template is also shown in a browser, put screen-only styling (shadows, sticky headers, a "Download PDF" button) behind@media screenso it does not appear on paper. - Backgrounds. Browsers tend to drop background colours when printing to save ink. If a coloured table header or a brand band matters, add
-webkit-print-color-adjust: exact; print-color-adjust: exact;to that element.
Here are those rules together, as a block you can drop into any template that is shown on screen and printed:
<style>
@media screen { .print-only { display: none; } }
@media print { .screen-only { display: none; } }
h1, h2 { break-after: avoid; }
tr, .signature, .card { break-inside: avoid; }
thead { display: table-header-group; }
.new-page { break-before: page; }
.brand-band { background: #e11d48; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
</style>break-after: avoid on headings stops the orphaned heading at the bottom of a page with its content on the next one.
The most common layout bug is not CSS at all: it is viewport-relative units. A hero section sized with 100vh looks fine on screen and produces an unexpected blank page in print. Size print elements in pt, mm or in.
A long line-items table is cutting rows in half across pages, and pages after the first have no column headings. Which pair of CSS rules fixes both?
05Fonts 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.
06Storing 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.
07Where 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/websites-and-markdown-to-pdf. The full parameter reference for this API is at docs.apiverve.com.