How to generate invoice PDFs from JSON with a template
Invoices are the most common reason developers need PDF generation. The data is structured (a customer, line items, tax), the layout rarely changes, and the output has to look right every time. The cleanest approach is a template plus JSON: designers own the HTML and CSS, your code only sends data. This guide builds a complete invoice template, shows how to render it yourself, then shows how to render it with PDFOven.
The data
Start with the JSON your billing code already has:
{
"number": "INV-1042",
"issued_at": "2026-03-01",
"due_at": "2026-03-31",
"currency": "USD",
"tax_rate": 8.5,
"seller": { "name": "Acme Studio LLC", "address": "100 Main St, Portland, OR" },
"customer": { "name": "Globex Corp", "email": "ap@globex.example" },
"items": [
{ "description": "Design retainer, March", "quantity": 1, "unit_price": 2400 },
{ "description": "Additional revisions", "quantity": 6, "unit_price": 95 },
{ "description": "Stock photography license", "quantity": 3, "unit_price": 49.5 }
],
"notes": "Payment by bank transfer within 30 days."
}
Keep money as numbers in the data and format it in the template. Never pre-format amounts as strings; you will need them for arithmetic.
The template
This is a complete Handlebars template. It uses PDFOven's built-in helpers: formatCurrency to format money, formatDate to format dates, multiply for per-line totals, lineTotal to sum quantity times price across all items, and add for the grand total.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { size: A4; margin: 0.6in; }
body { font-family: Helvetica, Arial, sans-serif; font-size: 12px; color: #1f2328; }
header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
h1 { font-size: 28px; margin: 0; letter-spacing: 0.04em; }
.muted { color: #6b7280; }
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
thead { display: table-header-group; }
th { text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; border-bottom: 1px solid #d0d7de; padding: 8px 0; }
td { padding: 10px 0; border-bottom: 1px solid #eaeef2; }
tr { break-inside: avoid; }
.r { text-align: right; }
.totals { margin-left: auto; width: 260px; margin-top: 16px; }
.totals div { display: flex; justify-content: space-between; padding: 4px 0; }
.grand { font-weight: 700; font-size: 14px; border-top: 2px solid #1f2328; margin-top: 6px; padding-top: 8px; }
</style>
</head>
<body>
<header>
<div>
<h1>{{upper "Invoice"}}</h1>
<div class="muted">{{number}}</div>
</div>
<div class="r">
<div>Issued {{formatDate issued_at}}</div>
<div>Due {{formatDate due_at}}</div>
</div>
</header>
<section class="parties">
<div><div class="muted">From</div><strong>{{seller.name}}</strong><div>{{seller.address}}</div></div>
<div><div class="muted">Bill to</div><strong>{{customer.name}}</strong><div>{{customer.email}}</div></div>
</section>
<table>
<thead>
<tr><th>Description</th><th class="r">Qty</th><th class="r">Unit price</th><th class="r">Amount</th></tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="r">{{quantity}}</td>
<td class="r">{{formatCurrency unit_price ../currency}}</td>
<td class="r">{{formatCurrency (multiply quantity unit_price) ../currency}}</td>
</tr>
{{/each}}
</tbody>
</table>
<div class="totals">
<div><span>Subtotal</span><span>{{formatCurrency (lineTotal items "quantity" "unit_price") currency}}</span></div>
{{#if tax_rate}}
<div><span>Tax ({{tax_rate}}%)</span><span>{{formatCurrency (multiply (lineTotal items "quantity" "unit_price") (multiply tax_rate 0.01)) currency}}</span></div>
{{/if}}
<div class="grand"><span>Total due</span><span>{{formatCurrency (add (lineTotal items "quantity" "unit_price") (multiply (lineTotal items "quantity" "unit_price") (multiply tax_rate 0.01))) currency}}</span></div>
</div>
{{#if notes}}<p class="muted" style="margin-top:32px">{{notes}}</p>{{/if}}
</body>
</html>
A few layout details that make invoices print well:
thead { display: table-header-group; }repeats column headers when the table spans pages.tr { break-inside: avoid; }stops a row from being split across a page break.- Flexbox and grid are fine here because the renderer is Chromium. They would not work in Dompdf or wkhtmltopdf.
On rounding: the helpers do plain floating-point math and formatCurrency rounds for display. If your tax rules require rounding per line or specific rounding modes, compute those amounts in your billing code and pass them in the data.
Rendering it yourself
With the handlebars package and Puppeteer you would register equivalent helpers, compile, and print:
import Handlebars from "handlebars";
import puppeteer from "puppeteer";
Handlebars.registerHelper("formatCurrency", (v, code) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: typeof code === "string" ? code : "USD" }).format(Number(v)));
Handlebars.registerHelper("multiply", (a, b) => Number(a) * Number(b));
Handlebars.registerHelper("add", (a, b) => Number(a) + Number(b));
Handlebars.registerHelper("lineTotal", (items, q, p) => items.reduce((t, i) => t + Number(i[q]) * Number(i[p]), 0));
const html = Handlebars.compile(templateSource)(invoice);
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({ format: "A4", printBackground: true });
await browser.close();
You would also need the formatDate and upper helpers, and everything from our Node.js guide about running Chromium in production applies.
Rendering with PDFOven
Send the template inline with the data:
import { readFile, writeFile } from "node:fs/promises";
const template = await readFile("invoice.hbs", "utf8");
const invoice = JSON.parse(await readFile("invoice.json", "utf8"));
const res = await fetch("https://pdfoven.com/v1/pdf", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.PDFOVEN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
template,
data: invoice,
options: {
print_background: true,
footer_template:
'<div style="font-size:8px;width:100%;text-align:center;color:#6b7280">' +
invoice.number + ' - page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
},
filename: invoice.number + ".pdf",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(error.code + ": " + error.message);
}
await writeFile(invoice.number + ".pdf", Buffer.from(await res.arrayBuffer()));
The helpers are already registered on the server, so nothing else is needed.
Saved templates
Once the design is stable, save it as a template in your PDFOven dashboard and reference it by ID. Then your billing code only sends data, and design changes do not require a deploy:
curl -X POST https://pdfoven.com/v1/pdf \
-H "Authorization: Bearer $PDFOVEN_KEY" \
-H "Content-Type: application/json" \
--data-binary @- -o INV-1042.pdf <<'EOF'
{ "template_id": "invoice", "data": { "number": "INV-1042", "currency": "USD", "items": [] } }
EOF
Other helpers
sum items "field" adds up a single numeric field, formatNumber value 2 formats to fixed decimals, eq a b is useful inside {{#if}} for status badges (for example {{#if (eq status "paid")}}PAID{{/if}}), and formatDate value "short" switches date style.
Practical notes
- PDFOven does not store rendered files. Save the PDF in your own storage if you need to re-send exactly the same document later.
- Errors are
{ "error": { "code", "message" } }. A429withquota_exceededmeans the monthly limit is reached. - The free plan includes 100 renders per month; paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000.
Want an invoice without writing code? The free invoice generator fills a template like this one from a form.
Skip the browser plumbing
PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.
Get a free API key