Skip to content

Generate invoices as PDF from JSON

, 6 min read

Most apps that send invoices already have the numbers in a database. The remaining work is layout: turning an order into a tidy, printable page. This guide keeps that layout in one HTML template and fills it with JSON for each invoice.

1. Decide the data shape

Send values already formatted the way they should print. Your billing code owns rounding, currency and tax; the template only places text.

invoice.json
{
  "number": "INV-2026-0142",
  "date": "3 Nov 2026",
  "due": "17 Nov 2026",
  "seller":   { "name": "Northwind Studio", "address": "12 Example Street, Sofia", "vat": "BG000000000", "iban": "BG00 XXXX 0000 0000 0000 00" },
  "customer": { "name": "Acme Robotics GmbH", "address": "Musterstraße 1, Berlin", "vat": "DE000000000" },
  "items": [
    { "description": "API integration, October", "qty": "12 h", "unit": "€85.00", "amount": "€1,020.00" },
    { "description": "PDF template design",      "qty": "1",    "unit": "€340.00", "amount": "€340.00" }
  ],
  "subtotal": "€1,360.00", "vatRate": "20%", "vat": "€272.00", "total": "€1,632.00"
}

2. Write the template

Plain HTML with Mustache tags. {{name}} is escaped, so a customer called <script> prints as text.{{#items}} repeats the row for each line item:

invoice.html (excerpt)
<table>
  <thead><tr><th>Description</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
  <tbody>
    {{#items}}
    <tr><td>{{description}}</td><td class="num">{{qty}}</td><td class="num">{{unit}}</td><td class="num">{{amount}}</td></tr>
    {{/items}}
  </tbody>
</table>
<p class="total">Total due {{total}}</p>
{{#customer.vat}}<p>Reverse charge: VAT ID {{customer.vat}}</p>{{/customer.vat}}

The full layout is in the invoice template. Use font-variant-numeric: tabular-nums on amount columns so digits line up.

3. Save it once

Paste the HTML into a new template in the dashboard and check the live preview with your sample JSON. You’ll get an id like tpl_8f2…. Or try the built-in invoice id first.

4. Render per invoice

Terminal
curl https://api.pagenull.dev/v1/templates/invoice/render \
  -H "Authorization: Bearer $PAGENULL_API_KEY" \
  -H "Content-Type: application/json" \
  -d @invoice-request.json \
  -o INV-2026-0142.pdf

# invoice-request.json: { "data": { …the JSON above… }, "filename": "INV-2026-0142" }

The same from Python, e.g. in a Celery task after payment succeeds:

invoices.py
import os, requests

def render_invoice(invoice: dict) -> bytes:
    res = requests.post(
        "https://api.pagenull.dev/v1/templates/invoice/render",
        headers={"Authorization": f"Bearer {os.environ['PAGENULL_API_KEY']}"},
        json={"data": invoice, "filename": invoice["number"]},
        timeout=60,
    )
    if res.status_code != 200:
        raise RuntimeError(res.json()["error"]["message"])
    return res.content  # attach to the email, or store it in your own bucket

Where the PDF lives

pagenull doesn’t keep a copy. Store the returned bytes wherever your invoices already live (S3, your database, the email attachment). Invoices are legal records in most EU countries, so keeping them under your own retention policy is simpler anyway. If you need a link to hand to a browser, pass deliverTo with a presigned upload URL for your own bucket, then share a signed download link from there. We upload the file and keep no copy.

Checklist before you send real invoices

  • Sequential invoice numbers and the legally required fields for your country (seller and buyer VAT IDs, tax breakdown, dates).
  • Long customer names and 50-line orders: does the table break cleanly onto page two?
  • Characters outside Latin-1 (Cyrillic, Greek, Polish ł) render in your chosen font.
  • Amounts in the PDF match your ledger to the cent, because you send them pre-formatted.

If your buyers need a machine-readable e-invoice (Factur-X / ZUGFeRD), send structured data to /v1/invoices instead. The e-invoice guide shows how.