Webhook-driven PDF workflows: async rendering at scale
Synchronous PDF generation feels simple until it isn't. A user clicks "Export," your API blocks for 12 seconds while headless Chrome renders a 40-page report, the request times out behind your load balancer, and the user clicks again. Now you're rendering it twice.
This pattern collapses fast. Once you're generating thousands of documents per day — invoices, contracts, statements, shipping labels — you need an async model: submit a render job, get notified when it's done, fetch the file. Webhooks are the cleanest way to wire this together.
Why sync rendering doesn't scale
PDF rendering is CPU- and memory-heavy. A single Chromium instance rendering a complex template can use 300–500 MB of RAM and 1–3 seconds of CPU time. Under concurrent load:
- HTTP timeouts (typically 30–60s) start firing on large documents.
- Your API workers get blocked, starving other endpoints.
- Retries from clients multiply the work.
- Autoscaling reacts too slowly to bursty workloads (end-of-month billing runs, for example).
The fix is to decouple submission from rendering. The client submits a job and moves on. A worker pool renders in the background. When the PDF is ready, your backend gets a webhook, and you notify the user — email, in-app, or polling endpoint.
The basic architecture
A webhook-driven pipeline has four parts:
- Submission endpoint — accepts the render request, returns a job ID immediately.
- Render worker — processes jobs (your own queue or a managed API).
- Webhook receiver — listens for completion events.
- Delivery layer — pushes the finished PDF to the user or downstream system.
Using a managed API like Kamy, steps 2 and part of 3 are handled for you. You submit a job, register a webhook URL, and Kamy posts back when the PDF is ready.
Submitting an async render job
Here's a typical async submission:
tsimport { kamy } from "@kamydev/sdk"; export async function queueInvoicePdf(invoice: Invoice, userId: string) { const job = await kamy.documents.render({ template: "invoice-v3", data: invoice, async: true, webhook: { url: "https://api.example.com/hooks/kamy", metadata: { userId, invoiceId: invoice.id }, }, }); return job.id; }
The call returns immediately with a job ID. No blocking, no timeouts. The metadata field is passed back unchanged in the webhook payload — use it to correlate jobs to your domain objects without a database lookup.
Receiving the webhook
The receiver needs three things: signature verification, idempotency, and fast acknowledgment.
tsimport express from "express"; import { verifyKamySignature } from "@kamydev/sdk"; const app = express(); app.post( "/hooks/kamy", express.raw({ type: "application/json" }), async (req, res) => { const signature = req.header("X-Kamy-Signature"); const valid = verifyKamySignature( req.body, signature, process.env.KAMY_WEBHOOK_SECRET!, ); if (!valid) return res.status(401).send("invalid signature"); const event = JSON.parse(req.body.toString()); // Acknowledge fast, process async res.status(200).send("ok"); await enqueueForProcessing(event); }, );
Two things to notice:
- We respond with
200before doing any real work. Webhooks should be acknowledged in under a second. Anything slower invites retries and duplicate processing. - The actual work happens in a job queue (BullMQ, SQS, whatever you use). The webhook handler is just a thin entry point.
The event payload
A completed render event typically looks like this:
json{ "type": "document.rendered", "jobId": "job_01HXYZ...", "status": "succeeded", "document": { "url": "https://cdn.kamy.dev/d/abc123.pdf", "expiresAt": "2025-01-15T12:00:00Z", "pages": 4, "bytes": 184302 }, "metadata": { "userId": "u_123", "invoiceId": "inv_456" } }
The signed URL is the cheapest way to deliver: don't proxy through your own servers if you can avoid it. If you need permanent storage, stream the file to your own S3 bucket from the worker.
Handling failures
Renders fail. Templates have bugs. Data is malformed. Plan for it.
tsasync function handleRenderEvent(event: KamyEvent) { if (event.status === "succeeded") { await persistDocument(event); await notifyUser(event.metadata.userId, event.document.url); return; } if (event.status === "failed") { await logRenderFailure(event); if (event.error?.retryable && event.attempts < 3) { await scheduleRetry(event.metadata.invoiceId); } else { await notifyOpsChannel(event); } } }
Distinguish retryable errors (transient network issues, timeouts) from permanent ones (template syntax errors, invalid data). Retrying a template bug just wastes compute.
Idempotency
Webhook providers retry on non-2xx responses, network errors, and timeouts. You will receive duplicate events. Always dedupe by jobId:
tsasync function enqueueForProcessing(event: KamyEvent) { const inserted = await db.processedEvents.insert({ jobId: event.jobId,