DEV Community

Stvens Matin
Stvens Matin

Posted on

Designing Idempotent Stripe Webhook Delivery for Generated Files

A successful payment is not the same thing as a successful order.

For a SaaS product that generates a personalized file after checkout, fulfillment usually crosses several systems: the browser, your database, Stripe, a PDF renderer, object storage, an email provider, and a download endpoint. Any one of them can fail independently.

We recently hardened this flow for a document-generation product. The most useful lesson was to stop thinking of the webhook as “the function that sends the PDF” and start treating it as an event that advances a persisted state machine.

The fragile version

A tempting first implementation looks like this:

if (event.type === "checkout.session.completed") {
  const pdf = await generatePdf(session.metadata);
  await sendEmail(session.customer_email, pdf);
}
Enter fullscreen mode Exit fullscreen mode

It is short, but it hides several assumptions:

  • The webhook will arrive only once.
  • The metadata contains everything needed to regenerate the document.
  • PDF generation will finish within the request lifetime.
  • Object storage and email will be available at the same time.
  • A retry will not send duplicate emails.
  • Support can determine where a failure occurred.

Those assumptions eventually break.

Persist the project before checkout

The browser should not be the only place that knows what was purchased.

Before creating a Stripe Checkout Session, we persist:

  • a project identifier;
  • the selected plan;
  • a normalized checkout email;
  • the complete document data;
  • the referenced photos and crop metadata;
  • a deterministic content hash;
  • a checkout-intent record.

Only after persistence succeeds do we redirect to Stripe.

This ordering gives us a durable recovery point. If the customer closes the tab after payment, the webhook still has enough identifiers to find the project and queue fulfillment. The success page is no longer responsible for making the order real.

Verify the webhook before doing work

The webhook route reads the raw request body and verifies the stripe-signature using the configured webhook secret. If the signature is missing or invalid, it returns an error without changing order state.

This sounds basic, but it has an architectural consequence: do not parse and transform the request body before signature verification. Stripe signs the exact payload bytes.

After verification, the handler extracts stable identifiers from the Checkout Session metadata. It does not trust a client-supplied “paid” flag.

Make the Stripe Session ID the purchase idempotency key

Stripe can retry webhook delivery. Your endpoint can time out after completing some work. A human can also trigger an operational retry.

We upsert the purchase using the Stripe Checkout Session ID as the unique key. Receiving the same completed session again updates the existing purchase rather than creating a second one.

Then we enforce one generation job per purchase and job kind:

(purchase_id, job_kind) must be unique
Enter fullscreen mode Exit fullscreen mode

If that job already reached email_sent, the handler returns an “already queued/completed” result. If it is queued or running, the handler leaves it alone. Only explicitly retryable failure states are reset to queued.

This is stronger than placing an in-memory lock around the webhook. Database uniqueness survives process restarts and concurrent requests.

Use explicit delivery states

Our generation job records statuses including:

queued
generation_running
pdf_generation_failed
email_failed
email_sent
Enter fullscreen mode Exit fullscreen mode

The purchase has a separate delivery status so the customer-facing order and the worker attempt can be inspected independently.

Why not use a single failed value? Because recovery actions differ:

  • A PDF-generation failure may require fixing input or renderer code.
  • A generation or storage-path failure may be transient infrastructure trouble.
  • An email failure may still leave a valid downloadable artifact.

Specific states turn an opaque support ticket into a targeted operation.

Bind the purchase to a content hash

For personalized exports, a paid entitlement should not mean “this browser can download anything.” It should mean “this purchase can download the document state that was reviewed and purchased.”

We calculate a deterministic hash from the canonical document data and relevant photo metadata. Object keys, array order, and optional values are normalized so logically equivalent documents produce stable input.

That hash is stored on the project, checkout intent, Stripe metadata, purchase, and entitlement payload. When an export is requested, the server recomputes the expected hash and verifies that the entitlement matches.

This prevents accidental cross-project export and makes post-payment editing an explicit feature rather than an unintended side effect.

Store generated artifacts privately

Generated customer documents should not live at predictable public URLs.

The worker writes the PDF to private object storage and records:

  • object key;
  • content type;
  • file name;
  • byte count;
  • SHA-256 checksum.

The customer receives a tokenized download URL. The download endpoint verifies the token and resolves the corresponding purchase artifact.

Checksums are useful beyond security. They allow support and tests to verify that an artifact was generated consistently and uploaded completely.

Keep generation and email separable

Email is a notification channel, not the artifact itself.

Our program flow can generate both the print-ready PDF and a print-instructions PDF, store them, then send an email containing the files or secure download links. If the email provider fails, the generated artifact still exists.

The worker marks email failures separately and records the last error. A retry can focus on the failed delivery stage instead of charging the customer again or regenerating unrelated state.

For larger systems, this is also where a durable queue should replace an in-process scheduled task. The key design is not the queue vendor; it is that the job record exists before work starts and has a recoverable state after every failure.

Record audit events around transitions

Logs are helpful, but they are not a durable business history.

We record structured audit events such as:

  • checkout project saved;
  • paid delivery queued;
  • delivery email sent;
  • delivery email failed;
  • paid delivery failed.

Each event includes relevant identifiers such as project ID, purchase ID, checkout-intent ID, Stripe Session ID, job ID, and plan.

This creates an answerable trail without searching unstructured production logs. It also helps distinguish duplicate webhook delivery from a duplicate purchase.

Return webhook errors deliberately

There is a trade-off between acknowledging the webhook quickly and asking Stripe to retry.

If the event cannot be authenticated, the endpoint should reject it. If a paid purchase cannot be persisted or queued, returning a failure lets the provider retry. Once a durable job exists, the webhook can acknowledge receipt and let the worker own subsequent retries.

The boundary is simple:

Do not return success until the information needed for eventual fulfillment is durable.

You do not need to finish the PDF or send the email inside the webhook. You do need to ensure the job will not vanish when the request ends.

A practical implementation checklist

Before shipping generated-file fulfillment, test these cases:

  1. The same checkout.session.completed event arrives twice.
  2. Two instances process the same event concurrently.
  3. The customer closes the browser immediately after payment.
  4. The project was not persisted before checkout.
  5. Required metadata is missing.
  6. PDF generation throws.
  7. Object storage fails after generation.
  8. Email fails after storage succeeds.
  9. A retry occurs after email_sent.
  10. The requested content no longer matches the purchased content hash.
  11. A support operator needs to identify the failed stage without reading application logs.

If these scenarios are encoded as integration tests and database constraints, the flow becomes much easier to trust.

The broader lesson

Payment fulfillment is a distributed workflow even when it lives inside one codebase. Idempotency is not a boolean option you add to a webhook. It emerges from stable identifiers, unique constraints, persisted job state, narrow retry rules, private artifact storage, and observable transitions.

The system discussed here powers paid PDF delivery for Funeral Program Maker, but the same structure applies to reports, certificates, exports, media renders, and any product that creates a file after payment.

Disclosure: I work on Funeral Program Maker, the product used as the implementation case study.

Top comments (1)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

the "webhook advances a state machine" framing is the right call and underused. the piece i'd underline: keep the state transition and the side effect separate. if the handler both flips state AND renders+emails the PDF inline, a Stripe retry or two racing events regenerates the file and double-sends. the pattern that holds up: the webhook does only an idempotent transition (dedup on event.id) and enqueues a job, then a worker renders with its own idempotency key so a retry produces the same artifact, not a second email. and since the download endpoint reads state, "paid but not generated yet" becomes a real status instead of a 404.