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:
- 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.
- Failed delivery, retry succeeds. First delivery 5xx'd because your downstream timed out. Stripe retries 5 minutes later. Same event, fresh invocation.
- 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.
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:
- Fail closed (return 500 to Stripe). Stripe retries; you delay processing until storage is back. Risk: long Stripe outage = lots of retries = error noise. Acceptable for high-stakes flows.
- Fail open (process without dedup). Risk: duplicate processing. Acceptable for idempotent side effects (e.g. "send welcome email" — at worst, customer gets two welcome emails).
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
- [ ] Use atomic "set if not exists" (Redis SETNX, DB UNIQUE constraint with ON CONFLICT) — never check-then-set
- [ ] Key on
event.id, not on a derived field (customer ID, amount, timestamp) - [ ] TTL the dedup key for at least 7 days (Stripe retries up to 3 days, then sends fresh attempts)
- [ ] Release the key on processing failure, so retries can re-attempt
- [ ] For high-stakes flows, use the three-state status (processing / done / failed) with stuck-claim recovery
- [ ] Pair webhook idempotency with outbound
Idempotency-Keyheader on Stripe API mutations - [ ] Decide fail-open vs fail-closed for your dedup storage outage scenario
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 →