HTML to PDF in Python with requests
, 5 min read
From Python, the shortest path from HTML to a PDF is one HTTP request. This guide uses requests, then adds the parts production code needs: error handling, retries and keeping the receipt.
The request
# pip install requests
import os
import requests
res = requests.post(
"https://api.pagenull.dev/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['PAGENULL_API_KEY']}"},
json={
"html": "<h1>Invoice #1001</h1><p>Total: €120.00</p>",
"format": "A4",
"margin": {"top": "20mm", "bottom": "20mm"},
},
timeout=60,
)
if not res.ok:
err = res.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
with open("invoice.pdf", "wb") as f:
f.write(res.content)Every error has the same JSON shape, { "error": { "code", "message", "docs" } }, and the message names the field that was wrong. The codes are listed in the API reference.
Retries without double billing
Retry on 429 (rate limit) and 503 (all renderers busy) after the seconds in Retry-After. Send the same Idempotency-Key and the same body on every attempt: a retry is rendered again (we keep no copy to hand back) but charged only once. Keys are remembered for 24 hours, for up to five retries.
import time
import uuid
def render_pdf(payload: dict, attempts: int = 4) -> requests.Response:
key = str(uuid.uuid4()) # one key per document, reused on every retry
for attempt in range(attempts):
res = requests.post(
"https://api.pagenull.dev/v1/pdf",
headers={
"Authorization": f"Bearer {os.environ['PAGENULL_API_KEY']}",
"Idempotency-Key": key,
},
json=payload,
timeout=60,
)
if res.status_code not in (429, 503) or attempt == attempts - 1:
return res
time.sleep(int(res.headers.get("Retry-After", "2")))Templates with JSON data
Save the layout once in the dashboard as a template with {{variables}}, then send only the data. The template id is shown in the dashboard.
res = requests.post(
f"https://api.pagenull.dev/v1/templates/{template_id}/render",
headers={"Authorization": f"Bearer {os.environ['PAGENULL_API_KEY']}"},
json={"data": {"number": "INV-2026-0142", "customer": "Acme Robotics GmbH", "total": "€1,666.80"}},
timeout=60,
)Serving it from Flask or Django
from flask import Flask, Response, abort
app = Flask(__name__)
@app.get("/invoices/<invoice_id>.pdf")
def invoice_pdf(invoice_id):
html = render_invoice_html(invoice_id) # your own templating
res = render_pdf({"html": html, "format": "A4"})
if not res.ok:
abort(502)
return Response(
res.content,
mimetype="application/pdf",
headers={"Content-Disposition": f'inline; filename="invoice-{invoice_id}.pdf"'},
)In Django the same body goes into HttpResponse(res.content, content_type="application/pdf").
Keep the receipt
res.headers["pagenull-receipt"] is a signed statement of the exact bytes you got back. Store it next to the PDF; a column in the same table is enough. This guide shows how to check it later.
While you build, add "test": true: test renders are watermarked, free and don’t count against your plan.