Skip to content

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.

Terminal window
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.

render_invoice.py
"""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 json
import os
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from 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
@dataclass
class Line:
description: str
quantity: float
unit_price: float
@dataclass
class Party:
name: str
email: Optional[str] = None
address: Optional[str] = None
@dataclass
class 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())

An invoice this size is a small job, so it renders inside the request and returns 200 with the URL already populated:

200 OK
{
"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")

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:

422 Unprocessable Entity
{
"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. 2

A 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.

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.

galley_requests.py
import os
from 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 path

A 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.

Run the script again with no changes:

Saved INV-1042.pdf — 1 page(s), 1 billable unit(s), cached: true

The 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.

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.