Home › Guides › Stripe webhook retries and duplicates

Stripe keeps retrying a webhook event (and sends duplicates): why, and how to stop it

Matching pack: Webhooks Done Right (for Stripe) (US$19), an Agent Skill for Claude Code and Cursor with reference endpoints for Express/TypeScript and Flask/Python that reply fast and process each event once. Read its test report (36 automated tests (18 Node, 18 Python), all passing, plus a clean TypeScript typecheck; locally signed fixtures, not live Stripe deliveries). Free: Stripe webhook signature checker, which runs in your browser.

The Event deliveries tab shows the same event failing again and again, or your app sent two welcome emails for one payment. Both come from the same place: Stripe only counts a delivery as done when your endpoint quickly answers with a 2xx, and even then it can deliver an event more than once.

The short answer

Source: Stripe docs, "Receive Stripe events in your webhook endpoint": Event delivery behaviors, Handle duplicate events, Quickly return a 2xx response (docs.stripe.com/webhooks). Checked 25 Oct 2026.

Why one event keeps coming back

The usual causes, matching the failures in Stripe's status-code table:

Why you see the same event twice

Stripe's docs say an endpoint "might occasionally receive the same event more than once", so a perfect handler still sees duplicates. A slow handler sees more: if Stripe gives up waiting, it retries even though your first run may have finished. Two things also catch people out:

Events can also arrive out of order, so don't assume invoice.paid comes after customer.subscription.created. Fetch the object from the API if you need its current state.

A handler that answers fast and runs each event once

Express and TypeScript, in the shape our reference endpoint uses. db.recordAndEnqueue stands for your own code: insert event.id into a table where it is the primary key, and enqueue the work in the same transaction.

app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    // req.body is the raw Buffer: Stripe signs the exact bytes it sent
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], endpointSecret);
  } catch {
    return res.status(400).send("bad signature");  // not from Stripe, or body was changed
  }
  // One row per event.id (primary key). Insert and enqueue in one transaction,
  // so a failed enqueue rolls back and Stripe's retry gets another go.
  const firstTime = await db.recordAndEnqueue(event.id, event);
  if (!firstTime) return res.sendStatus(200);       // duplicate delivery: already have it
  res.sendStatus(200);                              // answer before any slow work
});
// A worker reads the queue and does the real work (emails, access, accounting).
// Unknown event types: log and ignore. Never throw on them.

If the insert and the enqueue can't share a transaction, delete the event.id row when the enqueue fails and return 500, so Stripe's next retry is processed instead of skipped.

Stopping retries that are already queued

Fixing the handler is enough: the next scheduled retry will get a 2xx and stop. You can see when that is in the endpoint's Event deliveries tab. If an event can never succeed (for example you no longer need it), make the handler return 2xx for it rather than leaving it to fail for three days.

Source: Stripe docs, "Receive Stripe events in your webhook endpoint": Event delivery behaviors, Handle duplicate events, Quickly return a 2xx response (docs.stripe.com/webhooks). Checked 25 Oct 2026.

Let your agent build it the safe way

Webhooks Done Right (for Stripe) (US$19) is an Agent Skill for Claude Code and Cursor. This skill teaches Claude Code or Cursor to avoid them. It includes reference endpoints for Express/TypeScript and Flask/Python, atomic idempotency stores (including Postgres), a Postgres schema tested on a real database, and signed test fixtures. Its tests use locally signed fixtures checked with the real Stripe libraries; live deliveries from Stripe were not used.

See Webhooks Done Right (for Stripe) · Read its test report

Missed deliveries happen anyway. Stripe Access Drift Check, n8n pack (US$34, n8n) compares Stripe with your app every hour and tells you when a paying customer is locked out. See the Drift Check · Read its test report