The webhook records every event.id in a stripe_events table and skips duplicates with a 200, and the delivery email is decoupled so a replay cannot double-fulfil.
Last reviewed June 13, 2026 by SecureStartKit Team
The short answer
Stripe guarantees at-least-once delivery, so the same event can arrive more than once, and a valid signature proves authenticity, not uniqueness. If your handler is not idempotent, a duplicate checkout.session.completed can fulfil an order twice: two delivery emails, two grants, double-counted revenue. Record each processed event id, skip events you have already handled, and always return 200 for a duplicate so Stripe stops retrying.
Where it shows up: A Stripe webhook handler performs side effects (insert a purchase, send an email, grant access) without checking whether that event id was already processed, and returns a non-2xx on duplicates.
// app/api/webhooks/stripe/route.ts
const event = getStripe().webhooks.constructEvent(body, sig, secret)
if (event.type === 'checkout.session.completed') {
const session = event.data.object
// runs every time the event is delivered, with no dedup
await admin.from('orders').insert({ session_id: session.id, status: 'paid' })
await grantAccess(session.metadata.user_id)
await sendDeliveryEmail(session.customer_details.email)
}
return NextResponse.json({ received: true })A valid signature got the event in the door, but nothing records that this event was already handled. A second delivery re-runs the grant and re-sends the email.
// app/api/webhooks/stripe/route.ts
const event = getStripe().webhooks.constructEvent(body, sig, secret)
// claim the event id first; if it is already there, we have seen it
const { error: seen } = await admin
.from('stripe_events')
.insert({ id: event.id, type: event.type })
if (seen?.code === '23505') {
return NextResponse.json({ received: true }) // duplicate: ack with 200
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
await admin.from('orders').insert({ session_id: session.id, status: 'paid' })
await grantAccess(session.metadata.user_id)
await sendDeliveryEmail(session.customer_details.email)
}
return NextResponse.json({ received: true })Inserting event.id into a stripe_events table is the idempotency key. A unique violation (Postgres code 23505) means the event was already handled, so the handler acknowledges with 200 and does no work. Side effects run at most once.
try {
// purchases.id is the payment_intent (primary key)
await admin.from('purchases').insert({
id: session.payment_intent,
user_id: session.metadata.user_id,
status: 'completed',
})
} catch (err) {
// a duplicate event throws a unique violation and lands here
return NextResponse.json({ error: 'failed' }, { status: 500 })
}The primary key blocks a duplicate row, but the duplicate event becomes a 500. Stripe treats 500 as a failure and keeps retrying the same event, producing a retry storm for an event that was, in fact, already processed.
try {
await admin.from('purchases').insert({
id: session.payment_intent,
user_id: session.metadata.user_id,
status: 'completed',
})
} catch (err) {
// already processed: acknowledge so Stripe stops retrying
if (err?.code === '23505') {
return NextResponse.json({ received: true })
}
return NextResponse.json({ error: 'failed' }, { status: 500 })
}A unique violation now returns 200, because a duplicate is a success from Stripe’s point of view: the order is already recorded. Genuine failures still return 500 so they are retried.
Signature verification answers "is this event really from Stripe", not "have I seen this event before". Those are different questions, and only the first is solved by constructEvent.
Duplicates are normal, not exceptional. Stripe retries any event it did not see a 2xx for, and under at-least-once delivery it can send the same event more than once even when you did respond. A captured request with a still-valid signature can also be replayed. So a handler that fulfils on every event it receives will, eventually, fulfil twice.
This kit's handler shows the subtler shape of the problem. It inserts into purchases with the id set to the Stripe payment intent, which is the primary key. A duplicate event therefore hits a unique-violation, the insert throws, the catch returns 500, and Stripe, seeing a 500, retries again. The primary key accidentally prevents a duplicate row, but it does so by erroring, which produces a retry loop rather than a clean acknowledgement. And the delivery email is sent in the same try block after the insert: if the first event inserts the row but the email send fails, every retry now dies on the duplicate-key 500 before reaching the email, so the customer never gets their delivery. A handler that inserted without that primary key, or upserted naively, would simply send two emails.
Open your webhook route and look for an idempotency key. The tell is whether event.id is recorded anywhere before side effects run:
grep -rn "event.id\|stripe_events\|idempotency" app/api/webhooks
If event.id is never stored and checked, every delivery does the work. Next, check what the handler returns when a write conflicts. A catch block that returns 500 on a duplicate turns an already-processed event into an infinite retry; a duplicate should return 200.
Finally, replay a real event against your handler and watch for repeated side effects:
stripe events resend evt_123
If the second delivery sends a second email or grants access again, the handler is not idempotent.
Myth“A verified signature means the event is unique, so I do not need idempotency.”
Signature verification proves the event is authentic, not that it is the first time you have seen it. Authentic events are delivered more than once by design.
Myth“Stripe sends each event exactly once.”
Stripe documents at-least-once delivery and retries on any non-2xx or timeout. Building on exactly-once delivery means building on an assumption Stripe does not make.
Myth“My primary key prevents duplicates, so the handler is safe.”
A primary key prevents a duplicate row, but not a duplicate email or a second access grant, and if the conflict surfaces as a 500 it triggers endless retries. Idempotency needs to gate the side effects, not just one table.
Myth“I will make the email send idempotent later.”
The email is the side effect customers actually notice when it fires twice. Idempotency that covers the database row but not the notifications is only half the fix.
SecureStartKit handles this by default. The webhook claims each `event.id` in a `stripe_events` table before doing any work; a duplicate hits the primary key, is recognised as already processed, and returns 200 so Stripe stops retrying. The purchase row is written with `upsert`, so a replay cannot throw on the key, and the delivery and notification emails are wrapped in their own try/catch, so an email-provider failure cannot fail the webhook and force a retry. If processing genuinely fails, the handler releases the claim so Stripe can retry cleanly. The one-time `stripe_events` table ships in `supabase/schema.sql`.
Idempotency: handling duplicate events