Skip to content
Guide

Stripe webhook retries and idempotency

Stripe retries a failed delivery for up to three days with exponential back off, and the same event can legitimately arrive more than once. A handler that is not idempotent turns that safety net into duplicate charges.

Stripe's retry behaviour is a feature: a delivery that fails is not lost. It only becomes a problem when the handler treats the second arrival of an event as if it were the first.

How Stripe retries

  • Live mode — Stripe “attempts to deliver events to your destination for up to three days with an exponential back off”. Stripe does not publish the exact intervals; do not build logic that assumes a specific schedule.
  • Sandbox — events created in a sandbox are retried “three times over the course of a few hours”, so test-mode behaviour is not a rehearsal for production.
  • Disabled destinations — if the destination is disabled or deleted when a retry is attempted, Stripe prevents future retries of that event.

Anything other than a 2xx counts as a failure — including a Timed out attempt where your handler did the work but answered too late. See Stripe webhook timeout.

Why the same event arrives twice

Stripe states plainly that “webhook endpoints might occasionally receive the same event more than once”. Three routes to a duplicate:

  1. A retry after a non-2xx or a timed-out attempt.
  2. Two separate Event objects generated for the same underlying change — Stripe's own advice is to identify these with the ID of the object in data.object together with event.type, since the event IDs differ.
  3. A replay you triggered yourself after fixing a bug.

Make the handler idempotent

The durable version of “log the event IDs you have processed, and then don't process already-logged events” is a uniqueness constraint — not an if check, which two concurrent deliveries can both pass.

-- One row per processed event. The primary key does the work.
CREATE TABLE processed_events (
  event_id    text PRIMARY KEY,
  event_type  text NOT NULL,
  object_id   text NOT NULL,
  handled_at  timestamptz NOT NULL DEFAULT now()
);

-- Claim the event; a duplicate delivery loses the race and is skipped.
INSERT INTO processed_events (event_id, event_type, object_id)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

Then claim before you act, and acknowledge a duplicate without repeating the effect:

const claimed = await claimEvent(event.id, event.type, event.data.object.id);
if (!claimed) return res.sendStatus(200); // already handled — ack and stop

await applyEffect(event);                 // the part that must happen once

Two details matter. Claim and effect belong in the same transaction where the effect is a database write, otherwise a crash between them leaves the event marked handled and the work undone. And where the effect is an outbound call — a charge, a payout, an email — pass your own idempotency key derived from the event ID so the remote side dedupes it too.

Where HookWatch fits

HookWatch captures every delivery with its request, response, and attempt history, so a duplicate is visible rather than inferred: you can see the retry, the event ID it carried, and what your endpoint returned each time. When you replay after a fix, the same idempotency work protects you — read replay a failed webhook safely and webhook replay before re-sending anything that touches money.

For the provider-agnostic version of this — dedupe tables, transaction boundaries, and worked code — see webhook idempotency, and consuming Stripe webhooks for the full integration walkthrough.

Get started

Start debugging your webhooks.

Point one endpoint at HookWatch, capture a failure, and replay it once it’s fixed. Free during beta.