Signs that something ran twice: a customer gets two welcome emails, two license keys, or a credit balance that doubled. A digital-download store sees two orders in its database for one payment. Nothing failed. Your webhook handler did what it was told, twice.
Stripe documents this behavior. This post covers why one payment can reach your fulfillment code more than once, what to key deduplication on, and a small pattern that holds up even when two calls arrive at the same moment. Every claim links to the doc it comes from (see Sources at the end).
Why the same payment arrives more than once
1. Stripe can redeliver an event. Stripe's webhook docs: "Webhook endpoints might occasionally receive the same event more than once. You can guard against duplicated event receipts by logging the event IDs you've processed, and then not processing already-logged events."
2. Retries are normal. If your endpoint doesn't return 2xx in time, Stripe retries. In live mode it attempts delivery "for up to three days with an exponential back off". A handler that did the work and then timed out before responding will get the same event again. Manual resends add more: "Manually resending an event that failed to reach a webhook endpoint doesn't cancel Stripe's automatic retry behavior, even if it results in a 2xx status code."
3. Sometimes there are two different events. "In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in data.object along with the event.type." An event-ID check alone won't catch that case.
4. Your own success page is a second caller. Stripe's fulfillment guide recommends calling fulfillment both from the webhook and from the Checkout landing page, so the customer gets access right away. It then warns: "your fulfill_checkout function might be called multiple times, possibly concurrently, for the same Checkout Session."
5. Order isn't guaranteed. "Stripe doesn't guarantee the delivery of events in the order that they're generated." Stripe also warns that distinct events can share a timestamp, so "don't use created to determine event order or whether you've already processed an event. Track event IDs to identify duplicate deliveries instead."
What to key on
For one-time purchases through Checkout, Stripe's own requirements for fulfill_checkout give the answer. It must:
- "Correctly handle being called multiple times with the same Checkout Session ID."
- "Accept a Checkout Session ID as an argument."
- "Retrieve the Checkout Session from the API with the
line_itemsproperty expanded." - "Check the
payment_statusproperty to determine if it requires fulfillment." - "Perform fulfillment of the line items."
- "Record fulfillment status for the provided Checkout Session."
So the Checkout Session ID is the natural key. It's the same whether the call comes from checkout.session.completed, checkout.session.async_payment_succeeded, a retry, a resend, or the success page. An event ID only dedupes a single event. The session ID dedupes the payment.
Use event IDs as well if you log or process other event types. That's the guard Stripe describes for repeated deliveries.
The pattern: claim first, then fulfill
"Check whether it's done, then do it" has a race: two concurrent calls can both see "not done" and both proceed. Let the database decide instead, with a unique constraint and a single atomic insert.
-- Postgres (works the same on Supabase)
create table fulfillments (
checkout_session_id text primary key,
status text not null default 'processing',
created_at timestamptz not null default now()
);
// Called from the webhook AND the success page
async function fulfillCheckout(sessionId: string) {
// 1) Try to claim this session. Only one caller can win.
const claimed = await db.query(
`insert into fulfillments (checkout_session_id)
values ($1)
on conflict (checkout_session_id) do nothing
returning checkout_session_id`,
[sessionId],
)
if (claimed.rowCount === 0) return // already claimed: do nothing
// 2) Confirm it's paid, per Stripe's fulfillment guide
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
})
if (session.payment_status === 'unpaid') {
await db.query(`delete from fulfillments where checkout_session_id = $1`, [sessionId])
return // a later async_payment_succeeded event will call this again
}
// 3) Grant access / send the key / write the order
await grantAccess(session)
// 4) Record it
await db.query(
`update fulfillments set status = 'fulfilled' where checkout_session_id = $1`,
[sessionId],
)
}
PostgreSQL's docs describe the key piece: "ON CONFLICT DO NOTHING simply avoids inserting a row as its alternative action," and RETURNING returns values "based on each row actually inserted". If no row comes back, another call already claimed the session.
Two notes on this sketch:
-
Failures in step 3. If
grantAccessthrows, the row stays atprocessing. Either delete the row so a retry can claim it again, or have a cleanup job retry rows stuck inprocessing. Stripe's own recovery guide uses the same three states (is_processing_or_processed,mark_as_processing,mark_as_processed) for its backlog script. -
Duplicates still get
200. When the handler sees a session it already processed, it should return success. Stripe's guide: "When your endpoint receives an event you've already processed, skip it and return a200response to stop further retries." Returning an error just brings the event back.
Don't confuse this with Idempotency-Key
Stripe's API has idempotency keys, and they solve a different problem: your requests to Stripe. "When creating or updating an object, use an idempotency key. Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object." Keys can be "up to 255 characters long", and for API v1 they can be pruned "after they're at least 24 hours old".
Use them when fulfillment itself calls Stripe, for example to create a refund or a credit. They won't dedupe incoming webhooks. Only your own unique constraint does that.
Keep the handler fast
Many duplicates come from timeouts. Stripe: "Your endpoint must quickly return a successful status code (2xx) before any complex logic that could cause a timeout." For heavy work (PDF generation, sending email, calls to other APIs), Stripe suggests handling events with "an asynchronous queue". Verify the signature, record the session ID, queue the work, and respond. Fewer timeouts mean fewer retries to dedupe.
Checklist
- [ ] Fulfillment is keyed on the Checkout Session ID, with a unique constraint in the database
- [ ] The claim is a single atomic insert (
on conflict do nothing), not a read-then-write - [ ] Webhook and success page call the same function
- [ ]
payment_statusis checked, andcheckout.session.async_payment_succeededis handled for delayed methods - [ ] Already-processed events return
200 - [ ] Outbound Stripe writes in fulfillment send an
Idempotency-Key - [ ] The handler responds before slow work runs
No outside scan can see whether your fulfillment is idempotent, because it lives in your code and database. WebhookProof's free checker covers the parts that are visible from outside: whether Stripe can reach your endpoint and whether it rejects unsigned events, using 3 harmless requests. The duplicate-event steps are also written up here: Duplicate Stripe webhook events: make fulfillment idempotent.
WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-18.
Sources
- Stripe, Receive Stripe events in your webhook endpoint (duplicate deliveries, two Event objects, ordering,
createdwarning, retries, manual resend doesn't cancel retries, respond with 2xx quickly, async queue): https://docs.stripe.com/webhooks - Stripe, Fulfill orders, hosted Checkout (
fulfill_checkoutcalled multiple times and concurrently, six requirements,payment_status, async payment events, landing page plus webhook): https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted - Stripe, Manage webhook endpoints: process undelivered events (processing/processed states, return 200 for already-processed events): https://docs.stripe.com/webhooks/signature
- Stripe API reference, Idempotent requests (outbound idempotency keys, 255 characters, 24-hour pruning): https://docs.stripe.com/api/idempotent_requests
- PostgreSQL, INSERT (
ON CONFLICT DO NOTHING,RETURNING): https://www.postgresql.org/docs/current/sql-insert.html
Top comments (0)