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
- In live mode Stripe retries a failed delivery for up to three days with exponential backoff. In a sandbox it retries three times over a few hours. A redirect (
3xx), any4xxor5xx, a TLS error or a timeout all count as failures. - Return a
2xxquickly, before any slow work. Stripe's docs say to return it before, for example, marking an invoice paid in your accounting system. - Expect duplicates anyway. Record each
event.idyou have processed and skip ones you have already seen. Don't usecreatedfor this: distinct events can share a timestamp. - Don't throw on event types you don't handle. Log them and return
2xx, or better, stop subscribing the endpoint to them.
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:
- Timeout: the handler sends email, calls another API or writes to a slow database before it answers. Move that work to a queue or background job.
- Throwing on an unexpected event: for example code that assumes every
checkout.session.completedhas a subscription, so a one-time payment crashes it and returns500. 400from signature checks: the body was parsed and re-serialized before verifying, or the wrongwhsec_secret is used (test and live endpoints have different secrets). Each retry is signed again with a new timestamp, so a correct handler will accept it.- Redirects and auth: the URL redirects (for example http to https, or a missing trailing slash), or sits behind login or CSRF protection. Register the final URL and exempt the route from CSRF checks.
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:
- A manual Resend from the Dashboard (up to 15 days after the event) or
stripe events resendin the CLI (up to 30 days) doesn't cancel Stripe's automatic retries, even when it gets a2xx. - Sometimes Stripe creates two separate Event objects for one change. Those have different ids, so if a double action would hurt (a refund, an order), also key on the
data.objectid plusevent.type.
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