Skip to content

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

render.py
# 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.

render.py
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.

render.py
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

app.py (Flask)
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.