Use case
Generating invoice PDFs from Stripe webhooks on Cloudflare Workers
The most common PDF use case across the SaaS businesses I run, end-to-end on an edge runtime: a Stripe payment succeeds, an invoice PDF gets filled from a template, the file lands in object storage, and the customer gets emailed a link. The whole flow runs on a single Cloudflare Worker and completes in under 300ms.
The setup
You're running a SaaS. Stripe takes the payment. You want a PDF invoice in customer hands within seconds, regardless of whether they're in Lisbon, Lagos, or LA. You don't want to run a Node backend or spin up Lambda containers with headless Chrome.
The shape of the problem maps to a Stripe webhook handler.
Stripe fires payment_intent.succeeded at your
endpoint within seconds of the charge. The handler has ~10s
before Stripe times out and retries — plenty of room for a
fill-form call and a write to object storage, if both the
handler and the dependencies are on the edge.
The flow
- Stripe → CF Worker: webhook fires, signature verified
- Worker → KV: fetch the invoice-template PDF (bundled at build time or stored as a KV binary;
~10-30ms) - Worker → PDFops:
POST /api/fill-formwith the template + the Stripe data as field values (~100-200msincluding network) - Worker → R2: stash the filled PDF for later access (
~30-50ms) - Worker → email service: fire-and-forget a transactional email with the R2 download URL (
~10msvia webhook to Resend or similar) - Worker → Stripe:
200 OK
End-to-end: ~150-300ms. Stripe is happy, the customer gets the email in the time it takes them to switch tabs.
The Worker
Here's the whole thing — ~50 lines, TypeScript, no abstractions. The Stripe signature verification is omitted for brevity; use stripe.webhooks.constructEventAsync from the official Stripe SDK (it's edge-runtime compatible as of v14).
export interface Env {
INVOICES_KV: KVNamespace;
INVOICES_R2: R2Bucket;
STRIPE_WEBHOOK_SECRET: string;
EMAIL_WEBHOOK_URL: string; // e.g. Resend or your own
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
// 1. Verify Stripe signature (see Stripe docs — Workers-compatible)
const body = await req.text();
const event = await verifyStripe(req, body, env.STRIPE_WEBHOOK_SECRET);
if (event.type !== 'payment_intent.succeeded') {
return new Response('OK'); // ignore other event types
}
const intent = event.data.object;
// 2. Fetch the invoice template from KV (binary)
const templatePdf = await env.INVOICES_KV.get('invoice-template', 'arrayBuffer');
if (!templatePdf) return new Response('Template missing', { status: 500 });
// 3. Call PDFops /api/fill-form with template + Stripe data
const fd = new FormData();
fd.append('pdf', new Blob([templatePdf], { type: 'application/pdf' }), 'template.pdf');
fd.append('fields', JSON.stringify({
customer_name: intent.metadata.customer_name ?? 'Customer',
amount: (intent.amount / 100).toFixed(2),
currency: intent.currency.toUpperCase(),
invoice_id: `INV-${intent.id.slice(-8)}`,
date: new Date().toISOString().slice(0, 10),
}));
const pdfResp = await fetch('https://pdfops.dev/api/fill-form', { method: 'POST', body: fd });
if (!pdfResp.ok) {
// Return 500 so Stripe retries the webhook
return new Response(`PDFops error: ${await pdfResp.text()}`, { status: 500 });
}
const filledPdf = await pdfResp.arrayBuffer();
// 4. Stash in R2 with the Stripe payment ID as the object key
const r2Key = `invoices/${intent.id}.pdf`;
await env.INVOICES_R2.put(r2Key, filledPdf, {
httpMetadata: { contentType: 'application/pdf' },
});
// 5. Fire-and-forget email (use ctx.waitUntil for guaranteed delivery).
// The download URL below is guessable from the Stripe payment ID — for
// production, use an R2 presigned URL (S3-compatible API) or an authed
// Worker proxy. See the "auth boundary" paragraph below the code.
const downloadUrl = `https://invoices.your-domain.com/${r2Key}`;
fetch(env.EMAIL_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: intent.receipt_email,
subject: `Invoice INV-${intent.id.slice(-8)}`,
html: `<p>Your receipt: <a href="${downloadUrl}">${downloadUrl}</a></p>`,
}),
}).catch(() => {});
return new Response('OK');
},
};
That's the production-shape. Deploy it to Workers, point Stripe's webhook at the URL, drop your invoice template into KV, and you have a globally-distributed invoice generator paying ~$0 for the compute.
One auth-boundary footgun in the code above: the R2 object key is just the Stripe payment ID, so the resulting download URL is enumerable by anyone who can guess Stripe ID shapes — and they're not secret, they show up in customer receipts and support tickets. For real traffic, either generate a short-lived R2 presigned URL via the S3-compatible API (and email that instead of the bare path), or route downloads through a separate Worker that authenticates the customer before streaming the object. The code above keeps the happy-path visible; the download endpoint is where you put the auth check.
The template
The PDFops /api/fill-form endpoint expects a PDF with AcroForm fields whose names match the JSON keys you send. Build the template once in any PDF editor that supports form fields (Acrobat, Mac Preview, LibreOffice Draw, the pdftk CLI). Field names like customer_name and invoice_id are what the API binds against.
Upload the template binary to KV via wrangler kv:key put or whatever your deploy pipeline uses. ~50-200 KB is typical; well under KV's 25MB-per-value limit.
The cost math
At 50,000 invoices per month — a real number for a small but established SaaS — here's how the substrate matters:
- Headless Chrome on Lambda: ~$50/mo of compute, plus operational cost (Chrome layer, cold starts, the occasional render-failure rabbit hole). And it's not edge-deployed — every request hops to your Lambda region first.
- DocRaptor or similar hosted PDF API: $0.05–$0.20/doc × 50k = $2,500–$10,000/mo. Centralized, so latency from Europe / Asia is mediocre. Their pricing reflects their substrate, not their value.
- PDFops (post-beta): priced from the substrate's unit economics, not as a discount on incumbents. Per-document pricing lands when paid tiers open; the substrate (CF Workers + R2, no Chrome, no Lambda cold starts) puts the floor at a different number entirely. Globally distributed by default.
The wedge isn't that PDFops generates PDFs faster. It's that the hosting substrate it's built on has different unit economics, and that difference passes through to per-document pricing.
When this pattern doesn't fit
- You need HTML-to-PDF with rich CSS layout, web fonts, or page breaks driven by content. PDFops fills form fields in pre-designed templates; it doesn't render arbitrary HTML. For that, Puppeteer or a hosted HTML-to-PDF service is still the answer.
- You need watermarks, stamps, signatures, or text extraction. Those endpoints are on the roadmap (
/api/stamp,/api/sign,/api/extract-text) but not shipped yet. The waitlist is how I'm prioritizing. - Your invoice volume is > 10k/month today and you need it to work this week. The anonymous tier is 100/IP/month during beta; the higher-quota waitlist keys land when paid plans open. Reach out via the form's message field if you need to test at higher volume sooner.
Try it
The two endpoints used in this pattern are live:
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@invoice-template.pdf" \
-F 'fields={"customer_name":"Acme Co","amount":"1250.00","invoice_id":"INV-1001","date":"2026-05-13"}' \
-o filled-invoice.pdf
You'll get an invoice PDF back. From there it's just wiring it into a Stripe webhook handler shaped like the code above.
Questions, missing endpoints, edge-deploy weirdness? Drop a note on the waitlist form — the message field is the fastest way to influence what ships next.