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.
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
| Status | Code | What to do |
|---|---|---|
| 401 | invalid_api_key | Key revoked, or the account email is not confirmed |
| 429 | quota_exceeded | Monthly limit; response includes used and quota |
| 429 | rate_limited | Too many per second; back off and retry |
| 413 | payload_too_large | HTML over 2 MB — usually an embedded image; link it instead |
| 422 | render_failed | The 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.