Stripe sent the same webhook event twice: causes and how to make handlers idempotent
September 27, 2026
You check the logs and see it plainly: the exact same event.id, processed twice, four minutes apart. Maybe a customer got welcomed by email twice. Maybe a credit was applied twice. Nothing crashed, nothing errored — Stripe just sent it again, and your handler did exactly what it was told, twice.
This is not a bug in Stripe, and it is not rare. It is documented behavior: webhooks are at-least-once, never exactly-once. Your handler has to be the part of the system that makes duplicates harmless.
Why Stripe re-sends an event you already got
- Your endpoint didn’t answer in time. Stripe expects a response within a few seconds. If your handler does slow work (an external API call, a heavy DB write) before responding, Stripe may treat the request as failed and retry it — even though your handler finished the work a moment later.
- Your endpoint returned a non-2xx status, even accidentally. An unrelated exception thrown after the important work already ran still produces a 500, and Stripe reads that as “please resend.”
- Two webhook endpoints point at the same URL, or the same logical event is configured on both a general endpoint and an event-specific one.
- Someone resent the event manually from the Dashboard while debugging, and the original delivery had actually succeeded.
- A deploy landed mid-request. Your old server instance accepted the webhook and was about to respond when it got killed; Stripe never saw a 200, so it retries against the new instance, which processes it fresh.
None of these are misconfigurations worth chasing down one by one. They are all going to keep happening, in some combination, for as long as the integration exists.
The fix: an idempotency guard keyed on event.id
Every Stripe event carries a globally unique id (like evt_1N...). The fix is one table and one check: before doing any real work, try to record that this exact event.id has been seen. If the record already exists, you’re looking at a duplicate — acknowledge it with a 200 and stop, without repeating the side effects.
-- one migration, works on Postgres/MySQL/SQLite
create table processed_stripe_events (
event_id text primary key,
type text not null,
processed_at timestamptz not null default now()
);// app/api/stripe/webhook/route.ts (Next.js App Router)
import Stripe from 'stripe'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const signature = req.headers.get('stripe-signature')!
const payload = await req.text()
const event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!.trim()
)
// The idempotency guard: insert first, work only if the insert succeeded.
const inserted = await db.tryInsertProcessedEvent(event.id, event.type)
if (!inserted) {
// Already handled this exact event.id before. Acknowledge and stop.
return new Response('duplicate, already processed', { status: 200 })
}
// ...safe to do the real work now: grant access, send email, update rows.
return new Response('ok', { status: 200 })
}// lib/db.ts — the actual guard, backed by a unique constraint
export async function tryInsertProcessedEvent(eventId: string, type: string) {
try {
await sql`
insert into processed_stripe_events (event_id, type)
values (${eventId}, ${type})
`
return true // first time seeing this event.id
} catch (err) {
if (isUniqueViolation(err)) return false // duplicate delivery
throw err
}
}The unique constraint is doing the real work here, not application logic. Two requests can race each other and both attempt the insert at nearly the same instant — the database guarantees only one of them succeeds, and the other gets a clean, dependable rejection instead of a coincidence you have to hope doesn’t happen.
What not to key the guard on
- Not the object id (
sub.id,ch.id). The same subscription or charge legitimately produces many different events over its life; you’d be blocking real, distinct events from ever being processed. - Not a hash of the payload. It works until Stripe adds a field or changes key order in a future API version, and now your hash silently stops matching and duplicates sail through.
- Not “have I seen this customer today.” That’s a business rule wearing an idempotency costume; it will reject a second, legitimate event for the same customer on the same day.
event.id is the one value Stripe itself guarantees is stable and unique per delivery attempt of the same logical event. Retries of the same event reuse it; a genuinely new event always gets a new one.
Respond fast, do slow work after
Since slow responses are one of the direct causes of retries, the second half of the fix is structural: verify the signature, run the idempotency check, and respond 200 immediately. If the real work is slow — calling a third-party API, sending email, recalculating a report — hand it to a queue or a background job after you’ve already answered Stripe. A webhook handler’s job is to accept the event reliably, not to finish everything it implies before replying.
event.id appearing more than once. If you find any, and the second processing repeated a side effect (email, credit, provisioning), that confirms the gap — the fix above closes it going forward, but you may need to manually undo whatever the duplicate run already did.This is a specific case of a bigger problem
Duplicate delivery is the “it ran twice” version of webhook reliability. The mirror-image problem is “it silently didn’t run at all,” covered in your Stripe webhook returns 200, but it didn’t work. And if the handler never even got to run because the signature check rejected it, see Stripe webhook signature verification failed. Together, these are the three ways a webhook-based integration quietly drifts from what Stripe actually did.
Find out if it already happened to you
If duplicate processing has been silently doubling credits, seats, or emails for a while, the fastest way to know is to compare Stripe directly against your database. Run this reconciliation script once, or let Venwai do the comparison every day with a read-only Stripe key and alert you the moment something doesn’t match.
Free while it’s in beta, no card required: venwai.com.
Venwai compares Stripe with who your app actually lets in, once a day, using a read-only key. It alerts you the moment they disagree. Free while in beta, no card required.
Check my Stripe for free