Generate a PDF invoice from JSON in Python
Same task as the Node guide, in Python: take an order object, get back a PDF on disk. The primary script uses nothing but the standard library, so it runs inside a Lambda, a Django management command or a cron box without a requirements file.
Requires Python 3.9 or newer. There is no published Galley SDK yet — galley-render on PyPI is reserved but unpublished, so do not try to install it.
export GALLEY_API_KEY=glr_sk_…A keyless trial token from the MCP server works here too; trial tokens are ordinary API keys with a 50-render ceiling.
The whole script
Section titled “The whole script”"""Render an invoice PDF with Galley Render. Python 3.9+, standard library only.
Usage: GALLEY_API_KEY=glr_sk_… python render_invoice.py"""from __future__ import annotations
import jsonimport osimport sysimport urllib.errorimport urllib.requestfrom dataclasses import dataclass, fieldfrom typing import Any, Dict, List, Optional
API_BASE = "https://api.galleyrender.com"API_KEY = os.environ.get("GALLEY_API_KEY")TIMEOUT = 60
class GalleyError(Exception): """The API's error envelope, kept intact."""
def __init__(self, status: int, body: Dict[str, Any]) -> None: err = body.get("error", {}) if isinstance(body, dict) else {} super().__init__(err.get("message") or f"Galley returned HTTP {status}") self.status = status self.type = err.get("type", "internal_error") self.docs_url = err.get("docs_url") self.field_errors: List[Dict[str, Any]] = err.get("errors", []) self.details = err.get("details")
def galley(path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request(f"{API_BASE}{path}", data=data, method=method) req.add_header("Authorization", f"Bearer {API_KEY}") if data is not None: req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=TIMEOUT) as res: return json.loads(res.read().decode("utf-8")) except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8", "replace") try: parsed = json.loads(raw) except json.JSONDecodeError: parsed = {"error": {"message": raw[:500]}} raise GalleyError(exc.code, parsed) from None
# --------------------------------------------------------------- your domain
@dataclassclass Line: description: str quantity: float unit_price: float
@dataclassclass Party: name: str email: Optional[str] = None address: Optional[str] = None
@dataclassclass Order: number: str issued_on: str # "YYYY-MM-DD" due_on: str seller: Party buyer: Party lines: List[Line] = field(default_factory=list) tax_rate: float = 0.0 notes: Optional[str] = None currency: str = "USD"
def to_payload(self) -> Dict[str, Any]: """Map this dataclass onto the `invoice` template's data schema.
Keys omitted when empty: the schema allows them to be absent, and a `None` would fail the type check. """ def party(p: Party) -> Dict[str, Any]: out: Dict[str, Any] = {"name": p.name} if p.email: out["email"] = p.email if p.address: out["address"] = p.address return out
payload: Dict[str, Any] = { "invoice_number": self.number, "issued_on": self.issued_on, "due_on": self.due_on, "currency": self.currency, "seller": party(self.seller), "buyer": party(self.buyer), "line_items": [ { "description": line.description, "quantity": float(line.quantity), # numbers, not strings "unit_price": float(line.unit_price), } for line in self.lines ], } if self.notes: payload["notes"] = self.notes if self.tax_rate: payload["tax_rate"] = float(self.tax_rate) return payload
# -------------------------------------------------------------------- render
def render_invoice(order: Order) -> Dict[str, Any]: return galley( "/v1/render", method="POST", body={ "template": "invoice@1", # pin the version in anything you ship "format": "pdf", "data": order.to_payload(), "options": {"page_size": "Letter", "margin": "0.5in", "print_background": True}, }, )
def download(url: str, path: str) -> str: """The signed URL carries its own credentials — send no auth header.""" with urllib.request.urlopen(url, timeout=TIMEOUT) as res, open(path, "wb") as fh: fh.write(res.read()) return path
def main() -> int: if not API_KEY: print("Set GALLEY_API_KEY first.", file=sys.stderr) return 1
order = Order( number="INV-1042", issued_on="2026-09-16", due_on="2026-10-16", tax_rate=0.07, notes="Payment due within 30 days. ACH details on request.", seller=Party( name="Galley Render", email="billing@galleyrender.com", address="2727 Jean Lafitte Dr, Fernandina Beach, FL 32034", ), buyer=Party( name="Acme Robotics", email="ap@acme.test", address="100 Market St, Austin, TX 78701", ), lines=[ Line("Starter plan, September", 1, 19), Line("Overage, 3,200 renders", 3.2, 4), ], )
try: render = render_invoice(order) except GalleyError as exc: if exc.type == "validation_error": print("The payload does not match the invoice schema:", file=sys.stderr) for f in exc.field_errors: print(f" {f['path']}: expected {f['expected']}, received {f.get('received')}", file=sys.stderr) if "example" in f: print(f" e.g. {json.dumps(f['example'])}", file=sys.stderr) return 2 print(f"{exc.type} (HTTP {exc.status}): {exc}", file=sys.stderr) if exc.docs_url: print(exc.docs_url, file=sys.stderr) return 1
print(json.dumps(render, indent=2)) path = download(render["url"], f"{order.number}.pdf") print( f"Saved {path} — {render['page_count']} page(s), " f"{render['billable_units']} billable unit(s), cached: {render['cached']}" ) return 0
if __name__ == "__main__": raise SystemExit(main())What comes back
Section titled “What comes back”An invoice this size is a small job, so it renders inside the request and returns 200 with the URL already populated:
{ "object": "render", "id": "rnd_7hq2m4x8k1bv", "status": "succeeded", "template": "invoice@1", "format": "pdf", "engine": "chromium", "cached": false, "url": "https://<account>.r2.cloudflarestorage.com/galley-renders/renders/acc_9m3x/2026/09/4f1c…e7.pdf?X-Amz-Expires=3600&X-Amz-Signature=…", "expires_at": "2026-10-16T14:20:11.004Z", "page_count": 1, "billable_units": 1, "byte_size": 48213, "content_type": "application/pdf", "created_at": "2026-09-16T14:20:09.841Z", "completed_at": "2026-09-16T14:20:11.004Z", "error": null}The expires parameter inside url is the signed-URL expiry, one hour out. The top-level expires_at is retention — thirty days, after which the object itself is gone. For a stale URL, ask for a fresh one rather than re-rendering:
fresh = galley(f"/v1/renders/{render['id']}")download(fresh["url"], "INV-1042.pdf")Handling the 422
Section titled “Handling the 422”The invoice schema requires invoice_number, issued_on, seller, buyer and line_items, and quantity and unit_price must be numbers. Python makes this easy to get wrong, because a quantity read out of a CSV or a form field is a str. That is why to_payload coerces with float().
Remove the coercion and pass "1", and the request fails with 422 before anything renders or bills:
{ "error": { "type": "validation_error", "message": "The data payload does not match the template schema.", "docs_url": "https://galleyrender.com/docs/errors/validation_error", "errors": [ { "path": "data.line_items[0].quantity", "message": "data.line_items[0].quantity must be number", "expected": "number", "received": "string", "example": 2 } ] }}The script prints:
The payload does not match the invoice schema: data.line_items[0].quantity: expected number, received string e.g. 2A missing required field reads the same way — path: "data.issued_on", expected: "string (required)", received: "missing", example: "2026-09-16". Every error is machine-readable enough to drive a retry.
The requests variant
Section titled “The requests variant”If requests is already in your environment, the transport shrinks to this. Everything else in the script — Order, to_payload, the error handling — is unchanged.
import osfrom typing import Any, Dict, Optional
import requests
API_BASE = "https://api.galleyrender.com"SESSION = requests.Session()SESSION.headers.update({"Authorization": f"Bearer {os.environ['GALLEY_API_KEY']}"})
class GalleyError(Exception): def __init__(self, status: int, body: Dict[str, Any]) -> None: err = body.get("error", {}) super().__init__(err.get("message") or f"Galley returned HTTP {status}") self.status = status self.type = err.get("type", "internal_error") self.field_errors = err.get("errors", []) self.docs_url = err.get("docs_url")
def galley(path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: res = SESSION.request(method, f"{API_BASE}{path}", json=body, timeout=60) if not res.ok: try: parsed = res.json() except ValueError: parsed = {"error": {"message": res.text[:500]}} raise GalleyError(res.status_code, parsed) return res.json()
def download(url: str, path: str) -> str: # No auth header: the signature is in the URL. with requests.get(url, stream=True, timeout=60) as res: res.raise_for_status() with open(path, "wb") as fh: for chunk in res.iter_content(65536): fh.write(chunk) return pathA Session is worth having even for a single call: it keeps the TCP connection alive across the render and the download, which matters when you are generating a run of invoices in a loop.
The cache hit
Section titled “The cache hit”Run the script again with no changes:
Saved INV-1042.pdf — 1 page(s), 1 billable unit(s), cached: trueThe cache key is a hash of the template version, the format, and the canonical JSON of both data and options. Dict ordering does not affect it — the JSON is canonicalised before hashing — so a payload rebuilt from a dataclass in a different order still hits. A changed value, a changed option or a new template version does not.
Cache hits cost nothing and return immediately, so re-sending an identical request is cheaper and simpler than storing the PDF yourself.
Larger runs
Section titled “Larger runs”Above 32 KB of data, or with async: True, or with a webhook_url, the render is queued and the call returns 202 with status: "queued" and url: null. Poll it:
import time
render = galley("/v1/render", method="POST", body={ "template": "invoice@1", "format": "pdf", "data": order.to_payload(), "async": True,})while render["status"] in ("queued", "processing"): time.sleep(1) render = galley(f"/v1/renders/{render['id']}")if render["status"] == "failed": raise RuntimeError(render["error"]["message"])For a month-end run, POST /v1/render/batch takes up to 50 render requests in one call and queues them all, returning a batch_id and a per-item result — including a per-item error object for any item that failed validation, so one bad invoice does not sink the batch.
What next
Section titled “What next”- Generate a PDF invoice from JSON in Node — the same program in JavaScript.
- Batch render — up to 50 documents in one request.
- Errors — every error type and what to do about it.