Webhook-safe PDF generation: idempotency for Stripe and queue retries
Use idempotencyKey on every PDF generation call that's triggered by a webhook or a queue job, set to a value derived from the source event (e.g. the Stripe event ID or invoice ID), not a random UUID generated per attempt. Docweave stores the result keyed on that value for 24 hours and returns the original PDF on any repeat call instead of rendering again, so a redelivered webhook or a retried job produces one document and one usage event, not two.
Webhooks redeliver. That's not a bug, it's the contract.
Stripe, and basically every other webhook provider, guarantees at-least-once delivery. If your endpoint doesn't return a 2xx fast enough, times out, or your server hiccups mid-request, Stripe retries the same event — sometimes minutes later, sometimes the next day. Job queues (SQS, BullMQ, Cloud Tasks) work the same way: a worker that crashes after doing the work but before acking the message causes the job to run again. Neither of these is misbehaving. Your code has to assume duplicate delivery is normal and handle it, rather than treating each delivery as a fresh, independent request.
For most webhook handlers this means checking whether you've already processed the event ID before doing side effects. PDF generation is exactly the kind of side effect that goes wrong twice over if you don't: you burn a render on Chromium (cost), and if you're billing per document, you charge the customer twice for one invoice.
The concrete failure mode
A typical flow: invoice.payment_succeeded fires, your handler calls Docweave to render the invoice PDF and email it. If Stripe redelivers that event (network blip, your handler took too long, a deploy restarted mid-request), your handler runs again and calls Docweave again. Without protection you get two renders, two usage_event rows, and — if you attach the PDF and re-send the email — a customer getting the same invoice twice, which looks sloppy at best.
The same thing happens with queue-based batch jobs: a worker pulls a "generate monthly report" job, starts rendering, the process is killed by an autoscaler before it acks, and the message goes back on the queue for another worker to pick up. If the report generation isn't idempotent, you now have two reports in whatever storage bucket or CRM record you're writing to.
How idempotencyKey fixes it
Docweave's /api/v1/pdf endpoint accepts an idempotencyKey field. Send the same key twice within the dedup window and you get back the same result — the same PDF, the same response — without a second render happening and without a second usage_event being recorded. This is the same pattern Stripe itself uses for its own API (Idempotency-Key header), so if you've built against Stripe before, the mental model transfers directly.
The key is: choose a key that's stable across retries but unique per logical document. Good choices:
- The Stripe event ID (
evt_...) for webhook-triggered renders — every redelivery of the same event carries the same ID. - A composite like
invoice-{invoiceId}-v{version}if you might legitimately want to regenerate a PDF after the invoice is edited. - The queue message's deduplication ID or job ID for worker-triggered renders.
Bad choices: a crypto.randomUUID() generated inside the handler on every attempt. That defeats the purpose entirely — a fresh UUID on each retry looks like a brand-new request to Docweave, so retries still double-render. The key has to be computed from something in the triggering event, not from the attempt itself.
Example: Stripe webhook handler
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object;
const res = 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({
source: { templateId: 'invoice-template', data: toInvoiceData(invoice) },
// Stable across Stripe redeliveries of the same event:
idempotencyKey: event.id,
}),
});
if (!res.ok) {
// A real render failure — return non-2xx so Stripe retries.
return new Response('render failed', { status: 502 });
}
const pdfBuffer = Buffer.from(await res.arrayBuffer());
await emailInvoice(invoice.customer_email, pdfBuffer);
}
return new Response('ok', { status: 200 });
}Notice the handler still returns non-2xx on genuine failure, which is correct — you want Stripe to retry actual errors. The idempotency key is what makes those retries safe rather than something to avoid.
Same pattern over MCP
If your queue worker is an AI agent calling Docweave's MCP server instead of hitting the REST endpoint directly, the same idea applies — dedupe on your side using the job ID before invoking generate_pdf, since the tool call itself doesn't expose a server-side idempotency field the way the REST endpoint does. For direct MCP usage in a script:
await generate_pdf({
source: { templateId: 'monthly-report', data: reportData },
outputPath: `./reports/${jobId}.pdf`,
});Here the natural idempotency boundary is the job ID driving the output path — a re-run of the same job overwrites the same file rather than producing a duplicate under a new name.
What the dedup window actually does
Within the window, a repeated idempotencyKey short-circuits before rendering: Docweave looks up the stored result and returns it, so you don't pay for a second Chromium render and you don't get a second usage_event. After the window expires, the same key is treated as new — which is correct, since it means enough time has passed that a repeat call is more likely a deliberate re-generation than a stale retry. If you need a document to be regenerated intentionally (the invoice was corrected), change the key rather than fighting the cache — that's what the version-suffix pattern above is for.
This same idempotency behavior is available regardless of which language your webhook handler or worker is written in — see the Node.js, Python, Ruby, and Go integration guides for language-specific request examples. If you're wiring this up from a no-code automation tool instead of custom code, Zapier and Make flows can pass the triggering event's ID as the idempotency key the same way.
For the invoice use case specifically, the invoices page has a fuller template example, and the API docs cover the full request/response shape for /api/v1/pdf including error codes.
The takeaway
If a PDF render is triggered by anything that can redeliver — a webhook, a queue message, a retry-happy HTTP client — derive idempotencyKey from the triggering event, not from the attempt. It costs one field in the request body and it's the difference between "the webhook fired twice and nothing bad happened" and "we double-billed a customer for one invoice."
FAQ
What happens if I don't send idempotencyKey at all?
The request is treated as unique every time — Docweave renders and records a usage_event on every call. That's fine for one-off, human-triggered renders, but it means any retry (webhook redelivery, queue re-processing, a client-side retry-on-timeout) will produce a duplicate document and a duplicate usage_event.
How long does Docweave remember an idempotencyKey?
24 hours from the original render. A repeat call with the same key inside that window returns the stored result without re-rendering; after the window, the same key is treated as a new request.
Can I reuse the same idempotencyKey to intentionally regenerate a document?
Not within the window — that's the point of the guarantee. If the underlying data changed and you need a new PDF (e.g. a corrected invoice), use a new key, such as appending a version number: invoice-123-v2 instead of invoice-123.
Does idempotencyKey work the same way over the MCP server as it does over REST?
The underlying render orchestration is shared between the REST endpoint and the generate_pdf MCP tool, but the server-side dedup keyed on idempotencyKey is a REST-endpoint feature. For MCP-driven workers, the practical pattern is to dedupe on your side using the job ID (e.g. via the output path) before calling generate_pdf, since a redelivered agent instruction won't automatically be caught server-side the way a repeated REST call is.
Related
Generate your first PDF in minutes.
Get an API key