How to automate invoice PDF generation for your SaaS
Wire your billing webhook (e.g. Stripe's invoice.payment_succeeded) directly to Docweave's /api/v1/pdf endpoint: map the invoice payload to your template JSON, pass the Stripe event ID as the idempotencyKey, and store the returned PDF buffer. This turns invoice generation into a side effect of payment succeeding — no cron job polling for new invoices, no manual export step. Because Stripe redelivers webhooks on ambiguous responses, the idempotency key is what keeps a flaky network from producing two PDFs (and two support tickets) for one invoice.
Trigger on the event, not a schedule
Most teams that hand-roll invoice PDFs start with a nightly job: query invoices created since last run, render each one, upload it somewhere. It works, but it adds latency (customers wait until the next run to get a receipt), and it adds a whole class of bugs around "did I already process this row." The simpler design is to generate the PDF as a direct reaction to the event that makes the invoice real — for most SaaS billing, that's Stripe's invoice.payment_succeeded webhook (or the equivalent from Paddle, Chargebee, or whatever processor you use).
This post uses Stripe because it's the most common case, but nothing here is Stripe-specific — the pattern is: webhook fires → map payload to template data → call Docweave with an idempotency key derived from the event → store and deliver the PDF. If you're building the receipt or quote version of this same flow, see invoices, receipts, and quotes for the source data each use case typically needs.
The flow
- Stripe fires
invoice.payment_succeededat your webhook endpoint. - You verify the signature, then map the Stripe invoice object to your template's JSON schema.
- You call Docweave's
/api/v1/pdfwithtemplateId, the mappeddata, andidempotencyKey: event.id. - You store the resulting PDF (S3, your DB, wherever) and optionally email it to the customer.
1. Build the template once
Before touching webhook code, create a reusable invoice template in Docweave (HTML + placeholders, or upload one via the dashboard) and note its templateId. This is the same template you'd use for a manually-triggered invoice, so it's worth reading through invoices and the docs for the exact JSON shape a template expects before wiring up the webhook. A minimal payload shape looks like this:
{
"invoiceNumber": "INV-1042",
"customerName": "Acme Corp",
"customerEmail": "billing@acme.com",
"amountDue": "149.00",
"currency": "USD",
"lineItems": [
{ "description": "Pro plan — July", "amount": "149.00" }
],
"issuedAt": "2026-07-01T00:00:00.000Z"
}2. Verify and handle the webhook
Verify the Stripe signature first — always, even in a hobby project. Then map event.data.object (the Stripe invoice) into your template's data shape and call Docweave. Note the Accept: application/pdf header and Buffer.from(await res.arrayBuffer()) to get the raw bytes:
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook signature verification failed: ${err.message}`);
}
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object;
const pdfRes = await fetch('https://docweave.dev/api/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DOCWEAVE_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/pdf',
},
body: JSON.stringify({
templateId: 'tpl_invoice_v2',
data: {
invoiceNumber: invoice.number,
customerName: invoice.customer_name ?? invoice.customer_email,
customerEmail: invoice.customer_email,
amountDue: (invoice.amount_due / 100).toFixed(2),
currency: invoice.currency.toUpperCase(),
lineItems: invoice.lines.data.map((line) => ({
description: line.description,
amount: (line.amount / 100).toFixed(2),
})),
issuedAt: new Date(invoice.created * 1000).toISOString(),
},
idempotencyKey: event.id,
}),
});
if (!pdfRes.ok) {
console.error('PDF render failed:', await pdfRes.text());
// Ack Stripe anyway — don't let a render failure trigger endless webhook retries.
// Queue event.id for a retry job instead (see below).
return res.status(200).send('ok');
}
const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
await storeAndEmailInvoice(invoice, pdfBuffer);
}
res.status(200).send('ok');
});Why the idempotency key is non-negotiable here
Stripe (and every other webhook provider) will redeliver an event if your endpoint is slow to respond, returns a 5xx, or the connection drops mid-response — that's the entire point of a retry-based delivery guarantee. Without an idempotencyKey, a redelivered invoice.payment_succeeded produces a second render of the same invoice, which is at best wasted spend and at worst a duplicate PDF a customer receives twice. Passing event.id (Stripe's own dedupe key) as the idempotencyKey means Docweave returns the original result for a repeat request instead of rendering again — see the docs for the exact semantics.
3. Store and deliver the PDF
Once you have the buffer, upload it wherever invoices live in your system and, if you want the customer to get it immediately, attach it to an email:
async function storeAndEmailInvoice(invoice, pdfBuffer) {
const key = `invoices/${invoice.customer}/${invoice.number}.pdf`;
await s3.putObject({ Bucket: 'saas-invoices', Key: key, Body: pdfBuffer, ContentType: 'application/pdf' });
await mailer.sendMail({
to: invoice.customer_email,
subject: `Invoice ${invoice.number}`,
text: `Your payment was received. Invoice attached.`,
attachments: [{ filename: `${invoice.number}.pdf`, content: pdfBuffer }],
});
}What if the render fails
A render failure never throws mid-request — Docweave returns a normal HTTP response with a populated error and 502 status, so pdfRes.ok is false and you can branch on it cleanly rather than wrapping everything in try/catch for network-vs-render errors. The pattern above acks the Stripe webhook regardless (so Stripe stops retrying delivery) and logs the failed event.id to a separate retry queue — that decouples "did Stripe's event reach me" from "did the PDF render," which is worth understanding if you're new to programmatic PDF generation as a concept: the render step is a dependency call, not part of the webhook contract itself.
Regenerating a one-off invoice by hand
The webhook flow handles the automatic case, but support and finance teams will occasionally need to regenerate a specific invoice — a customer lost the email, or a template typo needs a corrected reprint. For that, the open-source MCP server is a better fit than writing a one-off script, since it plugs directly into Claude or any MCP-compatible agent:
// via `npx @docweave/mcp`, called by an agent or a quick internal tool
await generate_pdf({
source: {
templateId: 'tpl_invoice_v2',
data: { invoiceNumber: 'INV-1042', customerName: 'Acme Corp', amountDue: '149.00' },
},
outputPath: './invoices/INV-1042.pdf',
});That's the same template and the same rendering path as the webhook — just triggered manually instead of by an event. If your support tooling is itself agent-driven, Docweave for AI agents covers the broader pattern.
Before you ship this
- Confirm your template's data shape against real Stripe invoice objects — line item structures vary with proration, discounts, and tax.
- Pass
event.id(or another Stripe-guaranteed-unique value) asidempotencyKey, not a value you generate yourself. - Ack the webhook even on render failure; retry the render separately so Stripe doesn't hammer your endpoint.
- Estimate cost per invoice at your expected volume with the PDF cost calculator — pricing is per document, so a high-volume biller's math looks different from a low-volume one; see pricing for the plan breakdown.
- If you later add receipts, quotes, or contracts triggered the same way, they follow this identical webhook-to-template pattern.
FAQ
What happens if Stripe delivers invoice.payment_succeeded twice for the same invoice?
If you pass the Stripe event ID as Docweave's idempotencyKey, the second request returns the original render result instead of generating a new PDF. Without an idempotency key, you'd get two separate renders — and likely two emails to the customer.
Should I render the PDF synchronously inside the webhook handler?
For most invoice volumes, yes — a single render typically completes well within the response window Stripe allows before considering the webhook failed. If you're processing very high volumes or want to decouple retry logic entirely, ack the webhook immediately and push the render to a background job/queue instead.
Can I change the invoice template later without breaking old invoices?
Yes, as long as you don't overwrite the templateId in place for invoices you need to reproduce exactly. A common pattern is versioning template IDs (tpl_invoice_v2, tpl_invoice_v3) so historical invoices keep rendering with the template that was live when they were issued.
Does this only work with Stripe?
No — the pattern is identical for any billing provider with webhooks (Paddle, Chargebee, Recurly, etc.). The only Stripe-specific part is the payload shape you're mapping from and the signature verification step.
Related
Generate your first PDF in minutes.
Get an API key