# Stripe Webhooks Without Double Charges or Lost Orders | Nadya

> How to make Stripe payments safe to retry: idempotency keys, duplicate webhook events, signature checks, fulfillment that runs once, and daily reconciliation.

Canonical: https://nadiaconstructionstudio.com/blog/stripe-webhooks

[Home](https://nadiaconstructionstudio.com/) / [Blog](https://nadiaconstructionstudio.com/blog)

# Stripe Webhooks Without Double Charges or Lost Orders

By Nam Bok Rodriguez · October 16, 2026 · 6 min read

Double charges come from retrying payment requests without an idempotency key. Double or missing fulfillment comes from webhook handlers that assume each event arrives once and in order. The fix is four habits: send an idempotency key with every write to Stripe, record every event ID you process, make fulfillment safe to run twice, and reconcile your records against Stripe on a schedule.

Everything below comes from Stripe's own documentation, which is unusually clear on these points.

## Where do duplicates actually come from?

Duplicates are not a bug in Stripe. They are how reliable delivery over the internet works. There are two directions to worry about.

- **Your requests to Stripe.** A network error can cut the connection after Stripe processed your request but before you saw the response. Stripe's [error handling guide](https://docs.stripe.com/error-low-level) is blunt: restoring connectivity does not tell you whether the original request succeeded. Retry without protection and you can create a second charge.
- **Stripe's events to you.** According to the [webhooks documentation](https://docs.stripe.com/webhooks), Stripe retries delivery for up to three days with exponential backoff in live mode, endpoints might occasionally receive the same event more than once, and Stripe does not guarantee events arrive in the order they were generated.

Stripe also notes that in some cases two separate Event objects are generated for the same change. To catch those, it recommends using the ID of the object in data.object together with the event type, not just the event ID.

## How do idempotency keys prevent double charges?

An idempotency key is a unique string you send in the Idempotency-Key header. If the same request is sent again with the same key, Stripe returns the saved result instead of doing the work twice. Per Stripe's [idempotent requests reference](https://docs.stripe.com/api/idempotent_requests):

- All POST requests accept idempotency keys. GET and DELETE are idempotent by definition and do not need them.
- Keys can be up to 255 characters. Stripe suggests V4 UUIDs or another random string with enough entropy, and warns against using emails or other personal data.
- Stripe saves the status code and body of the first request once execution begins, including 500 errors, and replays them for retries.
- If a retry uses the same key with different parameters, Stripe returns an error rather than guessing.
- Keys are kept for at least 24 hours. Reusing a key after it is pruned can execute a new request.

The practical rules that follow:

- **Derive or persist the key per operation.** Stripe suggests either a random UUID or a key derived from your own object, such as a cart ID, which protects against double submits. If your app retries across separate calls or process restarts, store the key and reuse it on every attempt.
- **Do not mint a new key to "fix" an error.** If a 409 says another request with the same key is still running, wait and retry with that same key. A new key can start a second operation.
- **Let the SDK help.** Stripe's SDKs can generate keys and retry automatically with exponential backoff, and they respect the Stripe-Should-Retry response header.
- **Stop after the window.** If the outcome is still unknown after 24 hours, Stripe says not to retry with either key. Look up the object, check the Dashboard and use webhook events to reconcile.

## How should the webhook endpoint be built?

The endpoint has one job: prove the event is real, store it, and say thank you quickly.

- **Verify the signature.** Use the Stripe-Signature header, your endpoint's whsec_ secret and the raw request body. Stripe warns that any framework manipulation of the raw body breaks verification.
- **Keep the replay window.** The official libraries use a default tolerance of 5 minutes between the signed timestamp and now. Stripe explicitly says not to use a tolerance of 0, because that disables the check.
- **Return 2xx fast.** Stripe says your endpoint must return a successful status before any complex logic that could cause a timeout. Redirects count as failures.
- **Process asynchronously.** Stripe recommends an asynchronous queue, so spikes such as start of month subscription renewals do not overwhelm your servers.
- **Subscribe narrowly.** Listen only to the event types you handle.
- **Exempt the route from CSRF checks** in frameworks like Rails or Django, and roll signing secrets periodically.

## How do you make fulfillment run exactly once?

Stripe's [Checkout fulfillment guide](https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted) states that your fulfillment function might be called multiple times, possibly concurrently, for the same Checkout Session. It must:

- Handle being called multiple times with the same Checkout Session ID.
- Retrieve the session from the API with line items expanded.
- Check payment_status to decide whether fulfillment is needed.
- Fulfill the line items.
- Record fulfillment status for that session.

In practice we enforce this in the database, not in application logic alone. A table of processed event IDs with a unique constraint stops duplicate deliveries. A fulfillment record keyed by Checkout Session ID, written inside the same transaction as the fulfillment itself, stops the webhook and the success page from both shipping the order.

Two more details from the same guide matter:

- **Handle delayed payment methods.** Bank debits such as ACH succeed later and send checkout.session.async_payment_succeeded. Listen for it alongside checkout.session.completed.
- **Webhooks are required.** Triggering fulfillment from the landing page is a good addition for speed, but customers are not guaranteed to reach that page. Checkout waits up to 10 seconds for your webhook response before redirecting, so a fast endpoint helps both paths.

## What happens when something fails anyway?

Stripe treats a 500 response as indeterminate: the operation might have had side effects. Its guidance is to resolve the original outcome before retrying with a new key. Two habits make that possible:

- **Put your own ID in metadata.** Stripe recommends including a local identifier in each object's metadata, so an object that arrives only by webhook can be matched to your records.
- **Know how to replay events.** You can resend an event from the Dashboard for up to 15 days, or with the Stripe CLI for up to 30 days. Because your handler deduplicates, replays are safe.

## How do you reconcile with Stripe?

Idempotency prevents most problems. Reconciliation catches the rest. We run a daily job that compares orders in your database with the payments, refunds and disputes in Stripe for the same period, and flags anything that exists on only one side.

For the money side, Stripe's [payout reconciliation report](https://docs.stripe.com/reports/payout-reconciliation) matches each automatic payout to the balance transactions it settles, with itemized exports that can include your custom metadata. That metadata is what lets finance tie a bank deposit back to individual orders.

## A Stripe webhook reliability checklist

- Every POST to Stripe carries an idempotency key that is stored and reused on retries.
- Retries use exponential backoff and stop after 24 hours of unknown outcome.
- Webhook signatures are verified against the raw body with a nonzero tolerance.
- The endpoint returns 2xx immediately and hands work to a queue.
- Processed event IDs are stored with a unique constraint.
- Fulfillment is keyed by Checkout Session ID and recorded in the same transaction.
- No logic depends on event order or on the created timestamp.
- Delayed payment events are handled, not just checkout.session.completed.
- Your own order ID is in the metadata of every Stripe object you create.
- A daily reconciliation job compares your records with Stripe.

If your payments flow has ever charged someone twice or lost an order, or you are building one and want it right the first time, we can help. [Start a project](https://nadiaconstructionstudio.com/#start) and tell us how your checkout works today.

## Sources

- [Stripe: Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks)
- [Stripe: Idempotent requests](https://docs.stripe.com/api/idempotent_requests)
- [Stripe: Advanced error handling](https://docs.stripe.com/error-low-level)
- [Stripe: Fulfill orders](https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted)
- [Stripe: Payout reconciliation report](https://docs.stripe.com/reports/payout-reconciliation)

Start a project

## Tell us what you want *built.*

A few lines is enough. The person who reads it is the person who builds it.

- A reply within one business day
- A written scope with price and date before any work
- Code, infrastructure and accounts in your name

Prefer email? [nam@nadiaconstructionstudio.com](mailto:nam@nadiaconstructionstudio.com)
