A Stripe webhook handler. Receives payment events, processes them. The handler does not deduplicate by event id. Stripe retries any webhook that does not respond 2xx within 10 seconds — a slow request results in two complete processings of the same event.
The vulnerable diff
// webhooks/stripe.ts
stripeRouter.post("/webhook", async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.header("stripe-signature")!, SECRET);
if (event.type === "payment_intent.succeeded") {
// BUG: no deduplication. Stripe retries on slow response.
await processPaymentSuccess(event.data.object);
}
res.status(200).end();
});What is wrong
Webhooks are at-least-once delivery systems by design. The provider retries until it gets a 2xx response within the retry budget. Any handler whose response is slower than the provider's timeout (Stripe: 10 seconds for the first retry, exponential backoff after) will receive the same event multiple times. Without server-side deduplication by `event.id`, the handler runs business logic — fulfill order, send email, debit credit — once per delivery instead of once per logical event.
The attack
Symptom in production:
# Slow database write on payment-success processing (3 seconds).
# Stripe retries after 10s. Handler finishes processing #1 at t=12s.
# Handler #2 starts at t=10s, finishes at t=22s. Both completed.
# Result:
# - 2 orders created
# - 2 fulfillment workflows kicked off
# - 2 customer notification emails sent
# - 1 incident in #ops:
# "Customer received two fulfillment emails for one charge"
# Customer support refunds one. Operations team writes a runbook.
# Root cause: no event-id idempotency.All retryable-delivery systems share this problem: Shopify, PayPal, Razorpay, Twilio, Slack, GitHub webhooks. The fix is identical across providers.
Mesrai's review comment
mesraipilot · Bot · reviewed 90 sec ago
[mesrai] [code-review] [Logic] [Webhook-Idempotency] [critical]
Webhook handler does not deduplicate by `event.id`. Stripe retries
events that don't get a 2xx within 10s — same event processed twice
under slow responses.
Fix: persist a row per event id before doing the work; reject (200)
on duplicate delivery:
const seen = await db.webhookEvents.findUnique({ where: { id: event.id } });
if (seen) return res.status(200).end();
await db.webhookEvents.create({
data: { id: event.id, type: event.type, payload: event },
});
// Now do the work — guaranteed once-per-event
if (event.type === "payment_intent.succeeded") {
await processPaymentSuccess(event.data.object);
}
res.status(200).end();
For latency reasons, return 200 immediately and queue the processing
to a worker — but write the idempotency row before the queue insert
or use the same key on the worker's idempotency check.
Reference: Stripe — Best Practices for Receiving Webhook NotificationsThe fix
// webhooks/stripe.ts — fixed
stripeRouter.post("/webhook", async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.header("stripe-signature")!, SECRET
);
// Idempotency by Stripe event id
try {
await db.webhookEvents.create({
data: { id: event.id, type: event.type, receivedAt: new Date() },
});
} catch (e: any) {
if (e.code === "P2002") return res.status(200).end(); // duplicate
throw e;
}
if (event.type === "payment_intent.succeeded") {
await processPaymentSuccess(event.data.object);
}
res.status(200).end();
});Insert a row per `event.id` before doing the work. The database's unique-constraint violation on duplicate insert is the idempotency check — second delivery of the same event hits the constraint, we respond 200, no double processing. For high-latency processing, queue the work after the idempotency row write — the queue inherits the idempotency guarantee.
Why human review missed it
Webhook idempotency is missing from most pre-production integrations because the happy path works without it — Stripe delivers each event once unless there is a network or latency issue. The bug surfaces in production under load or when a downstream dependency is slow. Mesrai's rule pack flags every webhook handler that does not consult a persistent idempotency store before doing side effects.
Related rules + further reading
Mesrai rule pack: logic/webhook-idempotency-required — flags webhook handlers without persistent event-id deduplication.
Stripe docs: Best Practices for Receiving Webhook Notifications.
Common in fintech / e-commerce post-mortems; second-most-cited cause of double-charges after missing payment idempotency keys.
Takeaway
Every webhook handler needs event-id idempotency. Insert before processing. Duplicate insert means duplicate delivery — respond 200 and bail.