Skip to content

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&nbsp;&nbsp;&nbsp;{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:

  • @page sets the paper. Size and margins come from there, and body margin is 0 so the two don't add up. The @bottom-right box 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-family ends in sans-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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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_FAILED means 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, and pdfinfo tells you the page count.
  • Attach the bytes. Mail libraries take the PDF from memory. There's no need for a temp file.

Next steps

Try the HTML to PDF Converter API

Free tier is for development & small projects. 75 requests/day with a registered account.

cookies

We use analytics cookies to see how the site gets used. Nothing loads until you accept. Privacy policy