Turn HTML templates into PDFs

Render invoices and reports from the HTML you already know how to write. Get print CSS, page breaks and fonts right, then store the file before its signed link runs out.

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.

Try it liveHTML to PDF20 credits
POSThttps://api.apiverve.com/v1/htmltopdf
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>"}'
Sign in or create a free account to run this with your own key.

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:

FieldWhat it is
downloadURLA signed link to the generated file on a storage host
expiresWhen that link stops working, as a Unix timestamp in milliseconds
pdfNameThe generated file name, a random identifier plus .pdf
marginTop and the other marginsThe margins applied, as strings such as "0.4in"
landscapeWhether 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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`));

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.

Check yourself

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.

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales