Every side project I've ever built eventually hits the same wall: someone needs a PDF.
A receipt. A certificate. A report. And suddenly you're three hours deep in reportlab coordinate math, or you've spun up a headless Chromium just to print HTML, and your Docker image is 900 MB heavier for it.
My latest side project is a small Flask backend for running tech workshops people register, pay, attend, and get a certificate at the end. That's four different documents right there:
- a receipt when someone registers
- a certificate for every attendee (sometimes 80+ at once)
- an invoice for sponsors, and since I run events in Cairo, sometimes that invoice needs to be in Arabic, right-to-left and all
- a post-event report for myself and co-organizers
I decided to try pdfs.build for all of it, and this post is a walkthrough of how the whole thing wires together. Spoiler: my Flask app never touches a PDF library. It just sends JSON.
The mental model: define once, render millions
The core idea behind pdfs.build is simple. You design a template with dynamic variables ({{data.attendee_name}}, {{data.items}}, that kind of thing). Then your backend calls a REST endpoint with a template ID and a JSON payload, and gets a rendered PDF back, averaging under 400ms per render.
Templates and code are fully decoupled. If my co-organizer wants the certificate to look different next month, nobody touches the Flask app. That separation alone sold me.
Step 1: Build the templates (without designing anything from scratch)
I'm a backend person. My design skills peaked at bootstarp XD.
There are 160+ starter templates in the gallery, covering invoices, receipts, certificates, letters, reports, so I didn't start from a blank page for anything. I grabbed a certificate template and a receipt template and customized both.
The part that actually surprised me was the AI chat editor. You describe changes in plain English ("make the certificate landscape, add a signature line for the instructor, and put the workshop date under the title") and the assistant edits the template live while the preview re-renders in the browser. No compile-wait-check loop. I iterated on the certificate maybe ten times in fifteen minutes.
For the sponsor invoice I did something even lazier: our old invoices lived in a Word file, so I imported the .docx directly and let the editor turn it into a template. And for the Arabic version, I didn't build RTL layout myself; there's an Arabic invoice starter template in the gallery that handled the right-to-left layout properly out of the box.
One more thing I appreciated: templates have version control. Every edit is a tracked checkpoint, you can diff versions side by side, and roll back when the AI (or you) takes a wrong turn. I branched the certificate template to test a two-language variant without touching the one in production.
Step 2: Data binding, the contract between Flask and the template
Each template gets a JSON schema defining its variables. My certificate schema looks roughly like this:
{
"attendee_name": "string",
"workshop_title": "string",
"workshop_date": "string",
"hours": "number",
"instructor": "string"
}
The template adapts to the payload: the line-items table on the receipt grows and reflows with however many items you send. You test all of this in the editor with sample data before writing a single line of backend code, so by the time you integrate, you already know what the API expects.
Step 3: The Flask side
Here's the entire PDF "layer" of my app. One helper:
import os
import requests
PDFS_BASE = "https://api.pdfs.build/v2/organizations/{org}/templates/{template}/render"
API_KEY = os.environ["PDFS_API_KEY"]
ORG_ID = os.environ["PDFS_ORG_ID"]
def render_pdf(template_id: str, data: dict) -> bytes:
resp = requests.post(
PDFS_BASE.format(org=ORG_ID, template=template_id),
headers={"Authorization": f"Bearer {API_KEY}"},
json={"data": data},
timeout=30,
)
resp.raise_for_status()
return resp.content
And the registration endpoint that sends a receipt back:
from flask import Flask, request, send_file
from io import BytesIO
app = Flask(__name__)
@app.post("/register")
def register():
payload = request.get_json()
attendee = save_attendee(payload) # your usual DB stuff
pdf = render_pdf("workshop-receipt", {
"attendee_name": attendee.name,
"workshop_title": attendee.workshop.title,
"items": [
{"name": "Workshop ticket", "qty": 1, "price": attendee.ticket_price},
],
"date": attendee.registered_at.strftime("%d %b %Y"),
})
return send_file(
BytesIO(pdf),
mimetype="application/pdf",
download_name=f"receipt-{attendee.id}.pdf",
)
That's it. No wkhtmltopdf binary, no headless browser in the container, no font debugging at 2 AM. The Flask app stays a plain JSON-speaking service.
The API is standard REST with OpenAPI documentation, so generating a typed client is an option too. I just used requests because it's a side project and life is short.
Step 4: Batch-generating certificates for 80 people
After a workshop ends, I need one certificate per attendee. Rendering these sequentially works, but pdfs.build supports batch generation, firing off thousands of renders in parallel, which pairs nicely with webhook notifications: instead of holding a request open, you get a callback when generation completes.
So my "close the workshop" flow looks like:
@app.post("/workshops/<int:wid>/close")
def close_workshop(wid):
attendees = get_checked_in_attendees(wid)
for a in attendees:
enqueue_certificate_render(a) # batch render via the API
return {"status": "rendering", "count": len(attendees)}, 202
@app.post("/webhooks/pdfs")
def pdf_ready():
event = request.get_json()
# store the finished certificate, email it to the attendee
handle_completed_render(event)
return "", 204
The endpoint returns 202 immediately, the renders happen in parallel on their side, and my webhook route emails each certificate as it lands. For an 80-person workshop this turned a "go make coffee" job into a background non-event.
The unexpected bonus: MCP support
This one's very 2026: pdfs.build speaks the Model Context Protocol, so you can hand PDF rendering to an AI agent as a native tool. I connected it to Claude and can now say "render certificates for everyone who attended Saturday's Kubernetes workshop" and the agent calls the render tool itself.
Is it necessary? No. Is it extremely fun to generate documents by talking? Absolutely. If you're building agent workflows anyway, your document generation becomes just another tool in the toolbox instead of a bespoke integration.
Teams, if you're not flying solo
I co-organize with two other people, so team workspaces matter more than I expected: templates live in a shared workspace with shared fonts and brand assets, and role-based access means my co-organizers can tweak the certificate design without being able to touch API keys or billing. Design stays with designers (or design-adjacent people), code stays with me.
There's also an embeddable editor on the higher tier: you can put the template designer and its AI chat inside your own SaaS, so your users design their own documents. Overkill for a workshop app, but if my side project ever grows into "event platform for other organizers," that's the path.
Cost, for a side project
The free tier gives you 50 renders and 50 AI design credits a month with watermarked output, plenty for building and testing. Paid plans start at $10/month for 1,000 renders without the watermark. For a monthly workshop with under a hundred attendees, the math works out fine.
What I'd tell past me
Stop generating PDFs in your app process. Layout is a design problem, so treat it like one, and let your backend ship JSON instead of fighting page breaks. The AI editor with the live preview is the real killer feature here for backend developers like me: the gap between "I can describe what I want" and "I can design it" just closed. Version-controlled templates mean design changes stop being deploys, and batch plus webhooks turn bulk documents into an async background job with almost no code on my end.
If your side project has a "someone needs a PDF" moment coming, and it does, this is a much nicer wall to hit.
Have you fought the PDF-generation battle differently? I'd genuinely like to hear what stack you landed on. Tell me in the comments.





Top comments (0)