← Back to blogTry free

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

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

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.

Quick check: grep your logs for the same 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.

Is your Stripe out of sync with your app?

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