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.

terminal
# 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:

.env
TYPSETTER_API_KEY=ts_live_sk_your_key_here
config.py
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

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

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

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

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

app.py — 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

app.py — 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:

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

invoice_reportlab.py — abbreviated
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

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