Tutorials

Generate PDF invoices from Python in 20 lines

A complete, working example: invoice data as a dict, a template, one API call, a PDF on disk. Then the same thing for a batch of a hundred.

M Mikel Rougstone · 31 August 2026 · 3 min read
Generate PDF invoices from Python in 20 lines

You have invoice data in a database and need PDFs. There are three honest options: a PDF library where you draw every line (ReportLab, fpdf2), an HTML renderer you host yourself (WeasyPrint, Playwright), or an API. This is the API version, because it is the shortest path from data to document — twenty lines, including error handling.

One invoice

import requests

KEY = os.environ["PDFGENY_KEY"]

invoice = {
    "company": "Northwind Studio Ltd",
    "company_details": "12 Bridge Street\nManchester M1 2AB\nVAT GB123456789",
    "client": "Acme Corporation",
    "client_details": "500 Market Street\nSan Francisco, CA 94105",
    "number": "INV-2026-014",
    "issued": "31 August 2026",
    "due": "30 September 2026",
    "item1": "Design retainer, August", "qty1": "1", "price1": "2400.00",
    "item2": "Illustration set",        "qty2": "6", "price2": "120.00",
    "subtotal": "3120.00", "tax_label": "VAT 20%", "tax": "624.00", "total": "3744.00",
    "currency": "£",
    "terms": "Payment within 30 days by bank transfer.\nSort code 04-00-04 · Account 12345678",
}

r = requests.post(
    "https://pdfgeny.com/api/v1/render",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"template": "invoice", "data": invoice, "filename": f"{invoice['number']}.pdf"},
    timeout=60,
)
r.raise_for_status()
open(f"{invoice['number']}.pdf", "wb").write(r.content)

Line amounts are computed for you from quantity and price, so the template and your ledger cannot disagree about arithmetic. Fields you omit fall back to the template's example values, which means you can start with three fields and grow.

The full field list for any template comes from the catalog:

curl https://pdfgeny.com/api/v1/templates | jq '.templates[] | select(.id=="invoice") | .fields'

Your own layout

If the built-in invoice template does not match your brand, render your own HTML. Everything else stays identical:

from jinja2 import Template

html = Template(open("invoice.html").read()).render(invoice=invoice)
r = requests.post(
    "https://pdfgeny.com/api/v1/render",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"html": html, "format": "A4", "margin": "14mm",
          "footer_html": "Page {{page}} of {{pages}} · INV-2026-014"},
    timeout=60,
)

Guidance on writing templates that survive being paged — fixed geometry, defensive filters, print CSS — is in HTML templates for PDF.

A hundred invoices at month end

Month-end runs are where synchronous loops hurt: a hundred sequential requests take minutes and hold a connection open the whole time. Use the batch endpoint:

docs = [
    {"template": "invoice", "data": inv, "filename": f"{inv['number']}.pdf"}
    for inv in invoices
]

r = requests.post("https://pdfgeny.com/api/v1/batch",
                  headers={"Authorization": f"Bearer {KEY}"},
                  json={"documents": docs}, timeout=30)
batch_id = r.json()["batch_id"]

Then either poll, or let a webhook tell you:

status = requests.get(f"https://pdfgeny.com/api/v1/batch/{batch_id}",
                      headers={"Authorization": f"Bearer {KEY}"}).json()

for job in status["jobs"]:
    if job["status"] == "done":
        pdf = requests.get(job["document_url"]).content   # signed, valid 7 days
        save(job["id"], pdf)

Batches are capped at 100 documents per call — send 2,000 invoices as twenty calls, which also gives you natural checkpoints if something fails halfway.

Errors worth handling

StatusCodeWhat to do
401invalid_api_keyKey revoked, or the account email is not confirmed
429quota_exceededMonthly limit; response includes used and quota
429rate_limitedToo many per second; back off and retry
413payload_too_largeHTML over 2 MB — usually an embedded image; link it instead
422render_failedThe markup or URL failed to render; message says why

A minimal retry that behaves well:

from time import sleep

def render(payload, attempts=3):
    for i in range(attempts):
        r = requests.post(URL, headers=HEAD, json=payload, timeout=60)
        if r.status_code == 200:
            return r.content
        body = r.json().get("error", {})
        if body.get("code") in ("rate_limited",):
            sleep(2 ** i)
            continue
        raise RuntimeError(f"{r.status_code} {body.get('code')}: {body.get('message')}")
    raise RuntimeError("giving up after retries")

Do not retry invalid_input or render_failed — they will fail identically the second time.

Storing rather than downloading

For anything a customer will fetch later, ask for a stored document instead of bytes:

r = requests.post(URL, headers=HEAD, json={
    "template": "invoice", "data": invoice, "store": True, "response": "json"})
doc = r.json()
# {"document_id": "…", "document_url": "https://pdfgeny.com/api/v1/documents/…?exp=…&sig=…",
#  "size_bytes": 30581, "expires": "2026-09-07T…"}

The URL is signed and expires — good for an email link, and it keeps large attachments out of your mail queue. Seven days is the default; regenerate on demand after that.

What this replaces

For context, the same invoice in ReportLab is roughly 150 lines of coordinate arithmetic that has to be edited whenever the design changes. The HTML approach means a designer can change the template without touching Python. That, more than raw speed, is why most teams end up here.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.