The problem with Python PDF libraries
If you have ever tried to generate an invoice PDF with ReportLab, you know the pain. You are writing imperative Python code to position every text element, every line, every rectangle on a canvas. The layout logic is tangled with your business logic. Changing a font size means recalculating coordinates. Adding a new column to a table means rewriting the table builder function.
WeasyPrint is better — it renders HTML/CSS to PDF — but it pulls in Cairo, Pango, and a web of C library dependencies that are notoriously painful to install on Alpine Docker images and AWS Lambda. FPDF2 is lightweight but limited: no HTML support, no Unicode fonts out of the box, and manual positioning for everything.
The Typsetter API takes a different approach. You design your template once (in a visual editor or in Typst markup), then generate PDFs by sending JSON data to a REST endpoint. No layout code in Python. No C library dependencies. Just pip install requests and go.
Quick start: install and configure
You only need the requests library, which most Python projects already have. There is no heavy SDK to install.
# All you need — no C libraries, no system dependencies
pip install requests
# Optional: install the Typsetter Python SDK for a higher-level interface
pip install typsetter
SDK vs requests
All examples in this tutorial show both the raw requests approach and the SDK approach. Use whichever feels more natural. The SDK is a thin wrapper that handles authentication, retries, and file saving for you.
Authentication: get your API key
Sign up at typsetter.dev (free tier: 100 PDFs/month, no credit card). Your API key lives in the dashboard under Settings → API Keys. It looks like ts_live_sk_....
Store it in an environment variable — never hardcode secrets in source files:
TYPSETTER_API_KEY=ts_live_sk_your_key_here
import os
TYPSETTER_API_KEY = os.environ["TYPSETTER_API_KEY"]
TYPSETTER_BASE_URL = "https://api.typsetter.dev/v1"
Example 1: Generate an invoice PDF
This is the most common use case. You have an invoice template in Typsetter (either from the template store or one you built), and you want to fill it with data from your Python application and get a PDF back.
Using requests
import requests
import os
API_KEY = os.environ["TYPSETTER_API_KEY"]
def generate_invoice(invoice_data: dict, output_path: str) -> None:
"""Generate an invoice PDF and save it to disk."""
response = requests.post(
"https://api.typsetter.dev/v1/render",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"template": "invoice-professional",
"format": "pdf",
"data": invoice_data,
},
)
response.raise_for_status()
with open(output_path, "wb") as f:
f.write(response.content)
print(f"Invoice saved to {output_path} ({len(response.content)} bytes)")
# Usage
generate_invoice(
invoice_data={
"company_name": "Acme Corp",
"company_address": "123 Main St, San Francisco, CA 94102",
"client_name": "Globex Industries",
"client_email": "billing@globex.com",
"invoice_number": "INV-2026-0042",
"date": "2026-03-23",
"due_date": "2026-04-22",
"currency": "USD",
"items": [
{"description": "Web application development", "qty": 40, "unit_price": 150},
{"description": "UI/UX design consultation", "qty": 12, "unit_price": 175},
{"description": "Server setup & deployment", "qty": 1, "unit_price": 500},
],
"tax_rate": 8.5,
"notes": "Payment due within 30 days. Thank you for your business.",
},
output_path="invoice-0042.pdf",
)
Using the Typsetter SDK
from typsetter import Typsetter
client = Typsetter() # reads TYPSETTER_API_KEY from env
pdf = client.render(
template="invoice-professional",
data={
"company_name": "Acme Corp",
"client_name": "Globex Industries",
"invoice_number": "INV-2026-0042",
"items": [
{"description": "Web application development", "qty": 40, "unit_price": 150},
],
},
)
pdf.save("invoice-0042.pdf")
That is the entire integration. No canvas positioning. No HTML template rendering. No Chromium binary. The API returns the PDF binary in the response body, typically in under 400ms.
Example 2: Generate a certificate with custom data
Certificates are the second most common use case — course completions, awards, event attendance. The template handles the ornate design; your code just provides the recipient data.
import requests
import os
from datetime import date
def generate_certificate(recipient: str, course: str, completion_date: date) -> bytes:
"""Generate a completion certificate and return the PDF bytes."""
response = requests.post(
"https://api.typsetter.dev/v1/render",
headers={
"Authorization": f"Bearer {os.environ['TYPSETTER_API_KEY']}",
"Content-Type": "application/json",
},
json={
"template": "certificate-achievement",
"format": "pdf",
"data": {
"recipient_name": recipient,
"course_name": course,
"completion_date": completion_date.strftime("%B %d, %Y"),
"certificate_id": "CERT-2026-00789",
"instructor_name": "Dr. Sarah Chen",
"organization": "TechEd Academy",
},
},
)
response.raise_for_status()
return response.content
# Generate and save
pdf_bytes = generate_certificate(
recipient="Alice Johnson",
course="Advanced Python for Data Engineering",
completion_date=date(2026, 3, 20),
)
with open("certificate-alice.pdf", "wb") as f:
f.write(pdf_bytes)
Example 3: Batch generation from a CSV / pandas DataFrame
This is where the API approach really shines. Generating 500 personalized certificates from a CSV file is a loop, not a project.
import pandas as pd
import requests
import os
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
API_KEY = os.environ["TYPSETTER_API_KEY"]
OUTPUT_DIR = Path("output")
OUTPUT_DIR.mkdir(exist_ok=True)
# Load your data — CSV, database query, whatever you have
df = pd.read_csv("invoices.csv")
# Columns: client_name, client_email, amount, description, invoice_number
def render_one(row: dict) -> str:
"""Render a single invoice and return the output file path."""
response = requests.post(
"https://api.typsetter.dev/v1/render",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"template": "invoice-professional",
"format": "pdf",
"data": row,
},
)
response.raise_for_status()
out = OUTPUT_DIR / f"{row['invoice_number']}.pdf"
out.write_bytes(response.content)
return str(out)
# Generate up to 10 PDFs in parallel
rows = df.to_dict(orient="records")
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(render_one, row): row["invoice_number"] for row in rows}
for future in as_completed(futures):
inv_num = futures[future]
try:
path = future.result()
print(f"OK {inv_num} -> {path}")
except requests.HTTPError as e:
print(f"ERR {inv_num}: {e.response.status_code} {e.response.text}")
print(f"Done. {len(rows)} invoices generated in {OUTPUT_DIR}/")
Performance tip
With 10 parallel workers, you can generate 500 invoices in under 30 seconds. The API handles concurrency on the server side — each render is stateless and isolated. Increase max_workers up to 20 for higher throughput on paid plans.
Flask / FastAPI integration
The most practical pattern: an endpoint in your web app that generates a PDF on the fly and returns it to the browser (or triggers a download).
FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
import requests
import os
app = FastAPI()
API_KEY = os.environ["TYPSETTER_API_KEY"]
@app.get("/invoices/{invoice_id}/pdf")
def download_invoice_pdf(invoice_id: int):
# 1. Fetch invoice data from your database
invoice = get_invoice_from_db(invoice_id) # your ORM call
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# 2. Call Typsetter to render the PDF
resp = requests.post(
"https://api.typsetter.dev/v1/render",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"template": "invoice-professional",
"format": "pdf",
"data": invoice.to_template_data(),
},
)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="PDF generation failed")
# 3. Return PDF with download headers
return Response(
content=resp.content,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename=invoice-{invoice_id}.pdf"
},
)
Flask
from flask import Flask, make_response, abort
import requests
import os
app = Flask(__name__)
API_KEY = os.environ["TYPSETTER_API_KEY"]
@app.route("/invoices/<int:invoice_id>/pdf")
def download_invoice_pdf(invoice_id):
invoice = get_invoice_from_db(invoice_id)
if not invoice:
abort(404)
resp = requests.post(
"https://api.typsetter.dev/v1/render",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"template": "invoice-professional",
"format": "pdf",
"data": invoice.to_dict(),
},
)
if resp.status_code != 200:
abort(502)
response = make_response(resp.content)
response.headers["Content-Type"] = "application/pdf"
response.headers["Content-Disposition"] = f"attachment; filename=invoice-{invoice_id}.pdf"
return response
Your users hit GET /invoices/42/pdf, your server fetches the data from its database, sends it to Typsetter, and streams the resulting PDF back. The entire round-trip is typically under 500ms.
Error handling
Production code needs to handle API errors gracefully. Here is a robust wrapper with retries and proper error classification:
import requests
import os
import time
from typing import Optional
API_KEY = os.environ["TYPSETTER_API_KEY"]
BASE_URL = "https://api.typsetter.dev/v1"
class TypsetterError(Exception):
def __init__(self, status: int, message: str):
self.status = status
self.message = message
super().__init__(f"Typsetter API error {status}: {message}")
def render_pdf(
template: str,
data: dict,
retries: int = 3,
timeout: int = 30,
) -> bytes:
"""Render a PDF with retry logic for transient failures."""
for attempt in range(1, retries + 1):
try:
resp = requests.post(
f"{BASE_URL}/render",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={"template": template, "format": "pdf", "data": data},
timeout=timeout,
)
if resp.status_code == 200:
return resp.content
# Client errors (4xx) — don't retry
if 400 <= resp.status_code < 500:
detail = resp.json().get("error", resp.text)
raise TypsetterError(resp.status_code, detail)
# Server errors (5xx) — retry with backoff
if attempt < retries:
time.sleep(2 ** attempt)
continue
raise TypsetterError(resp.status_code, resp.text)
except requests.ConnectionError:
if attempt < retries:
time.sleep(2 ** attempt)
continue
raise
# Should not reach here, but satisfy type checker
raise TypsetterError(500, "Max retries exceeded")
Common API errors
401 — Invalid or missing API key. Check your TYPSETTER_API_KEY environment variable.
404 — Template slug not found. Verify the template exists in your dashboard.
422 — Validation error. A required template variable is missing from the data object.
429 — Rate limit exceeded. Back off and retry, or upgrade your plan.
Comparison: Typsetter API vs ReportLab
To make the difference concrete, here is the same invoice generated with ReportLab and with Typsetter. Both produce a professional A4 invoice with a header, line items table, and totals.
ReportLab: 65+ lines of layout code
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors
def create_invoice(data, output_path):
doc = SimpleDocTemplate(output_path, pagesize=A4,
leftMargin=20*mm, rightMargin=20*mm)
styles = getSampleStyleSheet()
elements = []
# Company header — manual positioning
elements.append(Paragraph(data["company_name"], styles["Title"]))
elements.append(Paragraph(data["company_address"], styles["Normal"]))
elements.append(Spacer(1, 12*mm))
# Client info block
elements.append(Paragraph(f"Bill to: {data['client_name']}", styles["Normal"]))
elements.append(Paragraph(f"Invoice: {data['invoice_number']}", styles["Normal"]))
elements.append(Spacer(1, 8*mm))
# Items table — you build every row manually
table_data = [["Description", "Qty", "Unit Price", "Amount"]]
for item in data["items"]:
table_data.append([
item["description"],
str(item["qty"]),
f"${item['unit_price']:.2f}",
f"${item['qty'] * item['unit_price']:.2f}",
])
t = Table(table_data, colWidths=[80*mm, 20*mm, 30*mm, 30*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), colors.HexColor("#333333")),
("TEXTCOLOR", (0,0), (-1,0), colors.white),
("GRID", (0,0), (-1,-1), 0.5, colors.grey),
("FONTSIZE", (0,0), (-1,-1), 9),
# ... 10+ more style rules for alignment, padding, alternating rows
]))
elements.append(t)
elements.append(Spacer(1, 6*mm))
# Totals — more manual layout
subtotal = sum(i["qty"] * i["unit_price"] for i in data["items"])
tax = subtotal * data["tax_rate"] / 100
elements.append(Paragraph(f"Subtotal: ${subtotal:.2f}", styles["Normal"]))
elements.append(Paragraph(f"Tax ({data['tax_rate']}%): ${tax:.2f}", styles["Normal"]))
elements.append(Paragraph(f"<b>Total: ${subtotal + tax:.2f}</b>", styles["Normal"]))
doc.build(elements)
# ~65 lines, and this is a simplified version.
# Real invoices with logos, colors, and footers easily reach 150+ lines.
Typsetter: 18 lines, same invoice
import requests, os
resp = requests.post(
"https://api.typsetter.dev/v1/render",
headers={
"Authorization": f"Bearer {os.environ['TYPSETTER_API_KEY']}",
"Content-Type": "application/json",
},
json={
"template": "invoice-professional",
"format": "pdf",
"data": {
"company_name": "Acme Corp",
"client_name": "Globex Industries",
"invoice_number": "INV-2026-0042",
"items": [
{"description": "Web development", "qty": 40, "unit_price": 150},
{"description": "UI/UX design", "qty": 12, "unit_price": 175},
],
"tax_rate": 8.5,
},
},
)
resp.raise_for_status()
open("invoice.pdf", "wb").write(resp.content)
The ReportLab version requires you to understand platypus, TableStyle coordinate syntax, unit conversions, and the document build pipeline. The Typsetter version requires you to understand requests.post(). The template — with its design, fonts, colors, and layout logic — lives in the Typsetter dashboard, not in your Python code.
| Criteria |
ReportLab |
Typsetter API |
| Lines of Python code |
65–150+ |
15–20 |
| Layout control |
In Python code |
Visual editor |
| Design changes |
Edit code, redeploy |
Edit template, instant |
| Dependencies |
reportlab (pure Python) |
requests (or none with SDK) |
| Typography quality |
Acceptable |
Excellent (Typst engine) |
| Batch generation |
Build it yourself |
Built-in + parallel API |
| Non-developer can edit template |
No |
Yes (visual editor) |
Conclusion
Python has excellent libraries for many things. PDF generation is not one of them. The existing tools — ReportLab, WeasyPrint, FPDF2 — all force you to mix layout concerns with application logic. Every font change, every column resize, every new field means editing Python code and redeploying your application.
The Typsetter API decouples document design from document generation. Your Python code sends data. The template handles the design. You can change the invoice layout, switch to a completely different template, or let a designer update the branding — all without touching a single line of Python.
The integration is trivial: requests.post() with a JSON body. The performance is fast: sub-400ms renders. The pricing is straightforward: 100 PDFs/month free, then pay as you grow. For Python developers who need to generate professional documents, this is the shortest path from data to PDF.
Start generating PDFs from Python in 5 minutes
Free tier: 100 PDFs/month. No credit card. API key in 30 seconds.