Stripe Retried the Webhook and We Almost Charged Twice: Claim event.id Before the Side Effect
Stripe delivers webhooks at-least-once: the same Event can land again for three days if your handler is slow, returns a non-2xx, or times out after the work already ran. A checkout that treats that retry as a fresh command will charge twice. This is the pattern note for claiming event.id in a processed-events table, pairing it with data.object.id plus event.type, and putting an Idempotency-Key on every outbound Stripe call — before the money side-effect.
Stripe Retried the Webhook and We Almost Charged Twice: Claim event.id Before the Side Effect
The most expensive checkout handler is the one that treats a Stripe webhook as a command. The payload looks like an order: checkout.session.completed, a session id, a customer, an amount. The naive read is "this arrived, so I should now take the money action" — grant the paid quota, capture a PaymentIntent, create an Invoice, fire a second PaymentIntent.create because Checkout somehow felt incomplete. Stripe's documented contract is the opposite of a command. Webhook delivery is at-least-once. The same Event can land on your endpoint again because the first attempt timed out, returned a 5xx, or never received a 2xx after the work had already run. A handler that is not idempotent will perform the money side-effect twice. This is a pattern note for Stripe webhook idempotency — claiming event.id in a processed-events table before the side-effect, pairing it with data.object.id plus event.type, and putting an Idempotency-Key on every outbound Stripe call — written for the kind of one-time checkout Swiss SME products actually ship.
We build that shape at tecminds. Acurio sells a thesis-verification package, not a subscription: Checkout collects once, checkout.session.completed unlocks a run quota, and nothing else should ever move money for that session. The same trap shows up on any Next.js or FastAPI checkout that fulfills from the webhook. The retry is not an incident and not a metric. It is the delivery contract. The double-charge is what happens when your handler ignores it.
The Delivery Contract Stripe Actually Publishes
Stripe's webhook docs are unusually explicit, and three clauses do all the work.
Retries are the product, not the exception. In live mode Stripe attempts delivery for up to three days with exponential backoff. Sandbox deliveries retry three times over a few hours. A timeout, a 5xx, a 4xx your framework emitted because CSRF ate the POST, a 3xx redirect Stripe treats as failure — all of them schedule another attempt. The Dashboard can manually resend an event for 15 days; the CLI can resend for 30. Manual success does not cancel the automatic retry schedule. If your handler's only memory of "I already did this" is the HTTP request that just finished, the next delivery has no idea.
The retry is the same Event. The id on the Event object — evt_… — is the unique identifier for that Event. A retry of a delivery that timed out still carries that id. The signature and timestamp do not stay the same: Stripe generates a new Stripe-Signature and timestamp on every delivery attempt, so a handler that dedupes on the raw signature header will treat a retry as new. Deduping on event.id is the documented guard: log the ids you have processed, and do not process already-logged events.
A 2xx after the work is the timeout trap. Stripe asks you to return a 2xx before any complex logic that could time out — "before updating a customer's invoice as paid in your accounting system" is their own example. The failure mode that charges twice is the composition of two reasonable lines. Line one: do the money work in the request so you "know it landed." Line two: return 200 at the end. If the work finishes and the response is slow — a lock, a DNS blip, a serverless cold start, a proxy that sat on the body — Stripe records a timeout and retries. The customer has already been billed. The retry looks, to a handler with no processed-events table, like a first delivery.
Order is also not a promise. Creating a subscription can emit customer.subscription.created, invoice.created, invoice.paid, and charge.created in any arrival order. A handler that charges on invoice.paid and on charge.created has invented a second writer without noticing.
Two Kinds of Duplicate, Two Keys
Stripe documents two different duplicates, and collapsing them into one SELECT is how the second one slips through.
The first kind is a retried delivery of the same Event. Same event.id. A processed_stripe_events row whose primary key is that id is enough. Insert on receipt; on unique-violation return 200 and stop. That is the whole retry story for a single Event object.
The second kind is two Event objects for the same logical action. Stripe says this happens: two separate Event objects are generated and sent, and the way to identify them is the id of the object in data.object together with event.type. evt_aaa and evt_bbb can both be checkout.session.completed for cs_…. A table keyed only on event.id will happily process both. The second unique constraint is (object_id, event_type).
Do not over-unify. checkout.session.completed and payment_intent.succeeded are different types on different objects. They are related, not duplicates. If both handlers fulfill the same order, you have two writers, and the fix is to pick one event as the source of truth for fulfillment — usually checkout.session.completed for Checkout, invoice.paid for Billing — not to unique-constrain unrelated types onto one row. The URL-token portal writeup already named the check-then-insert race this table will hit: two concurrent deliveries can both pass a SELECT before either INSERT. The unique constraint is the lock. Catching the integrity error and returning the same "already processed" path as the pre-check is what keeps the second delivery from becoming a 500 — and a 500 is another retry.
CREATE TABLE processed_stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
object_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (object_id, event_type)
);
status is load-bearing. A bare insert-as-done row cannot tell "we claimed this and then crashed" from "we finished." That distinction is the next section.
Claim, Then Act — and Make the Act Survive a Crash
The napkin version of webhook idempotency is "insert event.id, skip if present." The napkin version has a hole the same shape as the timeout trap, just on the other side of the write.
Act-then-claim is the double-charge. You capture the PaymentIntent or create the Invoice, then insert event.id. If the process dies — or Stripe times the request out — after the Stripe call and before the insert, the next delivery finds no row and does the call again. Two charges, one Event, one very short outage.
Claim-then-act with a terminal row is the silent skip. You insert event.id first, return 200 on conflict, then do the work. If you crash after the insert and still return 200 (or the insert committed and the worker never ran), Stripe is satisfied and will not retry. The customer was never billed, the quota was never granted, and no later delivery will notice. You have traded a double-charge for a dropped fulfillment.
The pattern that survives both is a two-state claim. Insert (event_id, status='processing') in the same statement that enforces uniqueness. If the insert loses, look at the existing row: processed means return 200 and stop; processing older than your handler budget means the first attempt died and this delivery is allowed to finish the side-effect, because the side-effect itself is idempotent. After the side-effect commits, flip the row to processed. The insert is the lock. The status is the crash record. The side-effect has to be safe to run twice for the stale-processing branch to be legal — which is why the outbound Stripe call needs its own key, not just the table.
This is the same discipline as the Postgres backstop sweep: one writer on the happy path, a unique constraint plus SKIP LOCKED anywhere two containers might claim the same row, and an explicit answer to "what if the process dies between the claim and the work." A rolling Coolify deploy that overlaps old and new webhook workers is two containers. They will both see the same Event if Stripe retries into the overlap. FOR UPDATE SKIP LOCKED on the claim, or a unique insert that can only succeed once, is the line that keeps that deploy from becoming a second charge.
For the kind of checkout Acurio ships, the fulfillment is not a second Stripe charge — Checkout already collected. The money-adjacent side-effect is "mark this session's package paid and unlock the quota." That write still needs the same claim. Two deliveries of checkout.session.completed that both UPDATE users SET credits = credits + 40 will double the quota; two deliveries that both call PaymentIntent.create because someone wired a "confirm payment" helper into the webhook will double the charge. The table does not care which side-effect you attached. It cares that you claimed the Event before either ran.
The Other Key People Mix Up With event.id
event.id stops you from processing the same inbound Event twice. It does nothing to the Stripe API call your handler makes on the way out. Those are different objects, different retries, different failure domains.
Stripe's API accepts an Idempotency-Key header. On API v1, two POSTs with the same key within 24 hours replay the first result instead of creating a second object. If your webhook handler creates a PaymentIntent, captures one, creates an Invoice, or refunds, and you do not send that header, a network retry from your process — not Stripe's webhook retry — will create a second object even when event.id is already claimed. The inbound table and the outbound header cover opposite directions.
Derive the outbound key from the business object, not only from event.id. A key of charge:${event.id} is safe for retries of that Event and unsafe for the second Event object Stripe warned you about. A key of charge:${checkout_session.id} or charge:${your_order_id} collapses both Event objects onto one Stripe-side replay. Store your own order id on the Checkout Session as metadata so the handler can recover it from data.object without inventing a new identifier on each delivery.
Three smaller rules ride along. Do not reuse an idempotency key after you change the request body — Stripe will reject the mismatch rather than silently apply new parameters. Do not treat a 500 from a creating call as "safe to retry with a fresh key"; Stripe tells you to treat that result as indeterminate, because the first attempt may already have side-effects. And do not put the webhook signing secret and the API key in the same mental bucket: one verifies inbound authenticity, the other authorises outbound mutation. Signature verification is still mandatory — without it an attacker can POST a fake checkout.session.completed — but a verified retry of a real Event is not an attack. It is Stripe doing what the docs said.
Three Rules for a Handler That May Run Twice
Three rules survive this writeup and generalise to any webhook that can move money or grant a paid entitlement:
Return 2xx before the money work, and persist the Event before either. The HTTP response is how Stripe decides whether to retry for the next three days. The processed-events row is how you decide whether this delivery is allowed to run. Doing the charge inside the request and inserting the row afterwards is the timeout-shaped double-charge. Returning 200 on a claim you never fulfilled is the crash-shaped dropped order. Insert processing, enqueue the work, return 200, let a worker finish and mark processed. Stripe's own best-practice line is the same shape: handle events asynchronously.
Key inbound on event.id, and also on (data.object.id, event.type). The first catches Stripe's retry of the same Event. The second catches two Event objects for one action. Neither one is a substitute for picking a single fulfillment event. If checkout.session.completed unlocks the package, payment_intent.succeeded is telemetry, not a second cashier.
Put an Idempotency-Key on every outbound Stripe POST, derived from the order, not from the delivery. The processed-events table cannot save you from your own HTTP client retrying PaymentIntent.create. The header can, for 24 hours on API v1, and only if the key names the business operation. event.id is the inbound claim. The order id is the outbound claim. You need both, and they are not the same string.
The composition is the whole note. Stripe will retry. The signature will look new. The Event id will not. A handler that believes "this POST is this charge" will bill twice the first time a response is slow. A handler that claims event.id in processing, fulfills once under an order-scoped idempotency key, and returns 200 before the slow work, will survive the three-day retry window and the manual Resend button and the deploy that overlapped two workers.
If you are wiring Checkout for a one-time package — or already have a webhook that fulfills from checkout.session.completed and have never logged a processed event.id — book a free AI Potenzial-Check. The Postgres backstop-sweep writeup is the durability half of the same "who is allowed to write this row" question; the URL-token portal note is the check-then-insert half.
acurio · Hallucinated citations? Not in your manuscript.
Citation checker for Zotero. Finds hallucinated or partially supported sources in AI‑written text. Thesis packages from CHF 19, Swiss data processing.