Generating receipts at checkout without slowing the checkout
The receipt PDF should never sit between the customer and the order confirmation. A pattern: confirm first, render asynchronously, attach when ready.
The worst place to render a PDF is inside the request that completes a purchase. A slow render, a busy renderer, a font server hiccup — and the customer stares at a spinner after entering their card details. Here is the pattern that avoids it, and the three details that make it reliable.
The pattern
1. Payment succeeds → order saved → confirmation page shown. No PDF yet. 2. Queue a render job with the order id and a webhook URL. 3. Webhook arrives → attach the receipt to the order → send the email with a link.
The customer gets confirmation in under a second and the receipt email a few seconds later. Nobody notices the gap; everybody notices a spinner.
# in the payment webhook, after the order is saved
job = requests.post("https://pdfgeny.com/api/v1/jobs",
headers={"Authorization": f"Bearer {KEY}"},
json={"template": "receipt",
"data": receipt_data(order),
"filename": f"receipt-{order.number}.pdf",
"webhook_url": "https://shop.example.com/hooks/pdf"},
timeout=15).json()
order.pdf_job_id = job["id"]
order.save(update_fields=["pdf_job_id"])
Detail one: idempotency
Payment providers retry webhooks — sometimes several times, sometimes days later. Without a guard you will render and email three receipts for one order.
Key the job on the order id and check before enqueueing:
if order.pdf_job_id or order.receipt_url:
return # already handled
Do the check inside the same transaction that marks the order paid, or use a unique constraint on (order_id, kind) in a small documents table. A retry then hits the database rather than the renderer.
Detail two: links, not attachments
A signed, expiring link is safer and cheaper than an attachment:
- The email stays small and is less likely to be filtered
- The document can be regenerated if the template had a bug
- Access can be revoked
- Your mail queue does not carry megabytes
{"template": "receipt", "data": {…}, "store": true, "response": "json"}
→ {"document_id": "…", "document_url": "https://…?exp=…&sig=…", "expires": "2026-09-07T…"}
Seven days is the default TTL. Keep the ability to regenerate on request — customers ask for receipts months later, usually at tax time.
Detail three: what belongs on a receipt
A receipt is not an invoice. An invoice asks for money; a receipt confirms it arrived. Buyers need the second document for their own bookkeeping, and issuing one immediately saves a support email.
Include: your legal name and address, a receipt number, the date payment was received, what it was for (order or invoice reference), the payment method, the amount received, and any remaining balance. Tax breakdown if you charged tax.
The receipt template covers these; for rentals there is a dedicated rent receipt, and charities need the specific acknowledgement wording in the donation receipt.
Handling the webhook
@app.post("/hooks/pdf")
def pdf_hook():
if not verify_signature(request.data, request.headers.get("X-PDFGeny-Signature")):
abort(400)
job = json.loads(request.data)["job"]
order = Order.objects.filter(pdf_job_id=job["id"]).first()
if not order:
return "", 200 # unknown job: acknowledge, do not retry
if job["status"] == "done":
order.receipt_url = job["document_url"]
order.save(update_fields=["receipt_url"])
send_receipt_email(order)
else:
alert_ops(f"receipt render failed for {order.number}: {job.get('error')}")
return "", 200
Two habits here: always return 200 for jobs you do not recognise (otherwise the sender retries forever), and alert on failures rather than swallowing them — a customer without a receipt will eventually write in.
When synchronous is fine
If the receipt is only downloaded on demand — a "Download receipt" button in the account area rather than an automatic email — rendering synchronously is simpler and perfectly acceptable at 0.6 s:
@app.get("/orders/<number>/receipt.pdf")
def receipt(number):
order = get_order_or_404(number)
pdf = render_receipt(order) # ~600 ms
return Response(pdf, mimetype="application/pdf")
The rule of thumb: never put a render between the customer and a confirmation; anywhere else, do whatever is simplest.
Volume
At checkout scale the numbers are undramatic. A thousand orders a day is a thousand renders — roughly eleven minutes of total render time spread across the day, comfortably inside the free tier's larger siblings. Peaks matter more than totals: Black Friday's hourly rate, not the daily one, decides whether you need the queue. Which you now have, because you used the async pattern.