An invoice is an HTML template, some numbers, and one API call. This guide builds one that you can drop into a project: a template that runs over as many pages as the invoice needs, the code that fills it in (Python and JavaScript), and the things to check before a customer does it for you.
Here's what comes out the other end, read back with pdftotext -layout (blank lines removed):
Invoice
INV-2026-0042
Northwind Studio 19 September 2026
12 Harbour Street, Bristol
Bill to
Fenwick & Sons <Ltd>
4 Mill Lane, Leeds
Item Qty Price Amount
Website redesign 1 $4,200.00 $4,200.00
Hosting, 12 months 12 $29.00 $348.00
Support hours 6 $95.00 $570.00
Total $5,118.00
Payment due within 30 days. Thanks for your business.
Page 1 of 1
The template
Save this as invoice.html. The {placeholders} get replaced later.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page {
size: A4;
margin: 18mm 16mm 22mm;
@bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt Arial, sans-serif; color: #66758a; }
}
body { margin: 0; font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #1f2933; }
.top { display: flex; justify-content: space-between; align-items: flex-start; }
h1 { font-size: 28px; margin: 0 0 4px; }
.muted { color: #66758a; }
table { width: 100%; border-collapse: collapse; margin-top: 32px; }
thead { break-inside: avoid; }
tr { break-inside: avoid; }
th { text-align: left; border-bottom: 2px solid #1f2933; padding: 8px 0; }
td { border-bottom: 1px solid #d9dee5; padding: 8px 0; }
.num { text-align: right; }
.closing { break-inside: avoid; }
.total { text-align: right; font-weight: bold; font-size: 16px; padding-top: 16px; }
.foot { margin-top: 40px; font-size: 12px; }
</style>
</head>
<body>
<div class="top">
<div>
<svg width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" rx="8" fill="#0b4f8a"/><path d="M11 28 L20 10 L29 28 Z" fill="#fff"/></svg>
<p><strong>{seller}</strong><br><span class="muted">{seller_address}</span></p>
</div>
<div class="num">
<h1>Invoice</h1>
<p class="muted">{number}<br>{date}</p>
</div>
</div>
<p><span class="muted">Bill to</span><br><strong>{customer}</strong><br>{customer_address}</p>
<table>
<thead>
<tr><th>Item</th><th class="num">Qty</th><th class="num">Price</th><th class="num">Amount</th></tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
<div class="closing">
<p class="total">Total {total}</p>
<p class="foot muted">Payment due within 30 days. Thanks for your business.</p>
</div>
</body>
</html>
A few things in there aren't style choices. They're how the HTML to PDF API renders when you send layout=document:
@pagesets the paper. Size and margins come from there, andbodymargin is 0 so the two don't add up. The@bottom-rightbox prints "Page 1 of 2" on every page. Chromium fills in the counters.- The logo is an inline
<svg>. Nothing is fetched from the network, so<img src="https://...">stays empty. For a PNG logo use a data URI:<img src="data:image/png;base64,...">. font-familyends insans-serif. Only fonts installed on the server exist. On my machine Arial comes out as Liberation Sans, which has the same letter widths. A brand font won't load.- Three lines of print CSS. They get their own section below.
The overview guide has the full list of rendering rules.
Fill it in
Two jobs here. Build the rows, and escape everything that came from a user. A customer called Fenwick & Sons <Ltd> would otherwise put a broken tag in your invoice.
Python
import os
import re
from html import escape
import requests
def money(amount):
return f"${amount:,.2f}"
def invoice_html(template, invoice):
rows = "".join(
f'<tr><td>{escape(item["name"])}</td><td class="num">{item["qty"]}</td>'
f'<td class="num">{money(item["price"])}</td><td class="num">{money(item["qty"] * item["price"])}</td></tr>'
for item in invoice["items"]
)
total = sum(item["qty"] * item["price"] for item in invoice["items"])
values = {key: escape(str(value)) for key, value in invoice.items() if key != "items"}
values.update(rows=rows, total=money(total))
return re.sub(r"\{(\w+)\}", lambda match: values[match.group(1)], template)
def invoice_pdf(invoice):
with open("invoice.html", encoding="utf-8") as f:
html = invoice_html(f.read(), invoice)
res = requests.post(
"https://apixies.io/api/v1/html-to-pdf",
json={"html": html, "layout": "document"},
headers={"X-API-Key": os.environ["APIXIES_API_KEY"]},
timeout=60,
)
if not res.headers.get("Content-Type", "").startswith("application/pdf"):
body = res.json()
raise RuntimeError(f"{body['code']}: {body['message']}")
return res.content
invoice = {
"seller": "Northwind Studio",
"seller_address": "12 Harbour Street, Bristol",
"number": "INV-2026-0042",
"date": "19 September 2026",
"customer": "Fenwick & Sons <Ltd>",
"customer_address": "4 Mill Lane, Leeds",
"items": [
{"name": "Website redesign", "qty": 1, "price": 4200.00},
{"name": "Hosting, 12 months", "qty": 12, "price": 29.00},
{"name": "Support hours", "qty": 6, "price": 95.00},
],
}
with open("INV-2026-0042.pdf", "wb") as f:
f.write(invoice_pdf(invoice))
The regex only touches {word} placeholders, so the braces in the CSS are left alone.
JavaScript
import { readFile, writeFile } from "node:fs/promises";
const escapeHtml = (value) =>
String(value).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const money = (amount) => amount.toLocaleString("en-US", { style: "currency", currency: "USD" });
function invoiceHtml(template, invoice) {
const rows = invoice.items
.map((item) => `<tr><td>${escapeHtml(item.name)}</td><td class="num">${item.qty}</td>
<td class="num">${money(item.price)}</td><td class="num">${money(item.qty * item.price)}</td></tr>`)
.join("");
const total = invoice.items.reduce((sum, item) => sum + item.qty * item.price, 0);
const values = { ...invoice, rows, total: money(total) };
return template.replace(/\{(\w+)\}/g, (_, key) => (key === "rows" ? rows : escapeHtml(values[key])));
}
async function invoicePdf(invoice) {
const template = await readFile("invoice.html", "utf8");
const res = await fetch("https://apixies.io/api/v1/html-to-pdf", {
method: "POST",
headers: { "X-API-Key": process.env.APIXIES_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html: invoiceHtml(template, invoice), layout: "document" }),
signal: AbortSignal.timeout(60_000),
});
if (!res.headers.get("content-type")?.startsWith("application/pdf")) {
const body = await res.json();
throw new Error(`${body.code}: ${body.message}`);
}
return Buffer.from(await res.arrayBuffer());
}
Call it with the same invoice object as the Python version and write the buffer with writeFile(). I rendered both, with 3 line items and with 45, and compared the pages pixel by pixel. They're identical.
Both send layout: "document". Forget it and you get the older single_page default, which fits everything onto one page in Arial and cuts off the rest, total included, with a 200. Both also check the Content-Type before returning. Errors come back as JSON with a status code, and you don't want to email a customer a file with INVALID_API_KEY inside. PHP and cURL versions of the request are in the code examples guide.
Do the money math in your backend, in whole cents or a decimal type. The floats here are fine for a demo and wrong for a ledger.
When the invoice runs over a page
With layout=document a long invoice gets more pages. I ran this template with 45 line items and got two pages, and with 60 I got three. Three lines of CSS decide whether those pages look right:
thead { break-inside: avoid; }
tr { break-inside: avoid; }
.closing { break-inside: avoid; }
thead { break-inside: avoid } repeats the header row. This one took me a while. A <thead> on its own didn't repeat, and neither did display: table-header-group. With break-inside: avoid on it, page two starts with Item, Qty, Price, Amount again.
tr { break-inside: avoid } keeps a row in one piece. A row that doesn't fit moves to the next page whole.
.closing keeps the total with the payment note. It's a div after the table, not a table row, so it can't be split from itself or end up as a lone line. With 19 items everything fits on one page. With 20 the rows still fit but the closing block doesn't, so it moves to page two as one piece. You never get a total on one page and the payment terms on the next.
The page counter comes from the @page rule at the top of the template. It's the only reliable way to number pages here. A position: fixed footer repeats on every page too, but from page two on it sits on top of your rows.
Before you ship it
- Store the PDF. Save it next to the invoice record when it's created. An invoice shouldn't change later because a template did, and every re-render costs one of your 75 free requests a day.
- Keep templates in git. When the design changes, old invoices still match what the customer got.
- Set a timeout and retry once. Rendering starts a real browser, so allow a few seconds. A
503 PDF_GENERATION_FAILEDmeans the renderer failed or timed out. Try again after a short pause. - Look at the output. Open a PDF from each new template yourself, once with three rows and once with fifty.
pdftotext invoice.pdf -is a quick way to assert in a test that the total made it into the file, andpdfinfotells you the page count. - Attach the bytes. Mail libraries take the PDF from memory. There's no need for a temp file.
Next steps
- Convert HTML to PDF with a REST API: every rendering rule in one place
- Code examples in cURL, JavaScript, PHP and Python
- HTML to PDF API reference
- HTML to PDF tool: a quick try in the browser, no key needed
- All guides