Stripe Webhook Idempotency: How to Handle Duplicate Events Without Double-Charging

Stripe will deliver the same webhook event multiple times. Sometimes within the same minute (network retries on a slow first response), sometimes hours apart (failed deliveries that succeed on retry). Without idempotency in your handler, you double-charge, double-email, or corrupt your database. Below: the exact patterns that make webhook handlers safely re-runnable, with working code for the common stacks.

Why duplicates happen

Three common causes:

  1. Network slowness. Your handler took 28 seconds to respond. Stripe gave up at 30 and retried. Your handler finished anyway, and the second delivery arrives 5 minutes later — same event, second invocation.
  2. Failed delivery, retry succeeds. First delivery 5xx'd because your downstream timed out. Stripe retries 5 minutes later. Same event, fresh invocation.
  3. Stale-event retry storm. Your secret was wrong all week; events 400'd. Bryant fixed the secret. Stripe drains its retry queue — every old event re-delivers in succession.

In all three cases, the event payload is identical. Stripe's event.id is the same. Your handler must recognize the duplicate and skip the side effects, or it does the work twice.

Worst case scenario: handler invokes charge.create twice on a duplicate event because you confused webhook events with API mutations. Customer sees two charges. Customer files chargeback. Customer never trusts you again. Idempotency is not optional.

The dedup-key pattern

Standard pattern: store every event.id you've successfully processed. On every invocation, check whether the event ID already exists. If yes, return 200 and skip the work. If no, process and then store.

Two ways to do the check, with different failure modes.

Pattern A — Check then set (BAD on its own)

// Simple but has a race condition
const seen = await kv.get(`stripe:event:${event.id}`);
if (seen) {
  return res.status(200).json({ received: true, dedup: true });
}

await processEvent(event);  // do the side effects
await kv.set(`stripe:event:${event.id}`, true, { ex: 86400 * 7 });
return res.status(200).json({ received: true });

Why it's broken: if Stripe delivers the same event twice in fast succession (e.g. network hiccup → instant retry), both invocations hit kv.get before either calls kv.set. Both see seen = false. Both process. You charged twice.

Pattern B — Set first, atomic claim (GOOD)

// Atomic "set if not exists" — only one invocation wins
const claimed = await kv.set(
  `stripe:event:${event.id}`,
  'processing',
  { nx: true, ex: 86400 * 7 }  // nx = only set if not exists
);

if (!claimed) {
  return res.status(200).json({ received: true, dedup: true });
}

try {
  await processEvent(event);
  await kv.set(`stripe:event:${event.id}`, 'done', { ex: 86400 * 7 });
} catch (e) {
  // Release the claim so a retry can re-process
  await kv.del(`stripe:event:${event.id}`);
  console.error('event processing failed', e);
  return res.status(200).json({ received: true, error: String(e) });
}

return res.status(200).json({ received: true });

The nx flag (or equivalent: SQL INSERT ... ON CONFLICT DO NOTHING) is atomic at the KV/database level. Only the first invocation sets the key; the second sees it already exists and bails immediately.

The release-on-failure pattern (kv.del in the catch block) is intentional: if your processing fails, you want a future retry to re-attempt. Without the delete, the key stays and the retry skips with "dedup."

Pattern C — Two-phase commit (BEST for high-stakes)

For webhooks where double-processing is catastrophic (financial transactions, irreversible state changes), use a two-phase pattern: claim first, do work, mark done.

// Phase 1: claim
const claimed = await db.query(
  `INSERT INTO webhook_events (event_id, status, claimed_at)
   VALUES ($1, 'processing', NOW())
   ON CONFLICT (event_id) DO NOTHING
   RETURNING event_id`,
  [event.id]
);

if (claimed.rows.length === 0) {
  // Already claimed (or done). Look up status.
  const existing = await db.query(
    `SELECT status FROM webhook_events WHERE event_id = $1`,
    [event.id]
  );
  // If status is 'processing' but claimed_at is older than 30s, it's
  // a stuck claim — release and let this invocation re-claim.
  // Skip the safety code here for brevity; assume done.
  return res.status(200).json({ received: true, dedup: true });
}

// Phase 2: do work
try {
  await processEvent(event);

  // Phase 3: mark done
  await db.query(
    `UPDATE webhook_events SET status = 'done', completed_at = NOW()
     WHERE event_id = $1`,
    [event.id]
  );
} catch (e) {
  await db.query(
    `UPDATE webhook_events SET status = 'failed', error = $2
     WHERE event_id = $1`,
    [event.id, String(e)]
  );
  return res.status(200).json({ received: true, error: String(e) });
}

return res.status(200).json({ received: true });

Status transitions: processing → done on success; processing → failed on error. A retry of a "failed" event can be configured to re-claim and re-attempt; a retry of a "done" event always skips.

Storage tier choices

Volume Storage Why
< 100 events/day fs.writeFile on Vercel KV / Upstash Free tier covers it; latency is irrelevant
100-10k events/day Upstash Redis or Vercel KV Fast, cheap, atomic SETNX
10k-1M events/day Postgres unique-constraint table or DynamoDB Persistence + ACID guarantees + analytics potential
1M+ events/day Hire a DevOps engineer Patterns past this point need real ops + monitoring

What about non-Stripe idempotency keys?

Stripe's API also supports passing an Idempotency-Key header on outbound API calls (e.g. when you call stripe.charges.create or stripe.refunds.create). This is different from webhook event IDs — it's for your client requests, not Stripe's webhook deliveries.

Use both. Webhook idempotency on inbound events; Idempotency-Key on outbound mutations. They're complementary.

// Outbound — pass an idempotency key
await stripe.charges.create(
  { amount: 100, currency: 'usd', source: 'tok_visa' },
  { idempotencyKey: `charge-${userId}-${Date.now()}` }
);

If your retry logic re-runs stripe.charges.create with the same key within 24 hours, Stripe returns the original response without charging twice.

The "what if dedup KV is down" question

If your dedup storage (KV / DB) is unavailable, you have a choice:

Pick the one that matches the consequence of duplicate processing for your specific event handler. checkout.session.completed → send_email can fail open. checkout.session.completed → charge_user_account must fail closed.

Summary checklist

Want this implemented in your repo without writing it yourself?

$199 flat (first 3 clients, then $399). Includes idempotency, sig verification, customer + founder email, atomic dedup with your storage of choice.

See the offer →

Related