Mesrai
Back to blog
// essayTechnical Deep Dive

Stripe Webhook Race: Processing the Same Event Twice

Real PR catch: webhook handler with no event-id idempotency — slow responses trigger Stripe retries, same event processed twice. DB unique-constraint fix.

Mesrai TeamJuly 31, 20268 min read

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

tswebhooks/stripe.ts
// 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:

textexploit
# 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

textreview-comment.txt
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 Notifications

The fix

tswebhooks/stripe.ts (fixed)
// 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.