An idempotent webhook handler produces one business effect whether the event arrives once, twice or five times at once. That is how to make a webhook handler idempotent: record the event id and apply the effect together, because Stripe says an endpoint might occasionally receive the same event more than once, and it retries failed live-mode deliveries for up to three days.
What idempotency means, and how to make a webhook handler idempotent in one sentence
A webhook handler is idempotent when the same event, delivered any number of times, produces one business effect. The mechanism is the provider’s event id, recorded in your database in the same transaction as the effect, so a duplicate id means the work is already done.
This is one control among the SaaS billing process best practices, the one that decides whether a retried payment event grants access, credits a balance or sends an email twice. Idempotent, in plain words, means doing it again changes nothing. Webhook idempotency is that property on the receiving end: the second, third and fifth delivery of one event find the work done and leave it alone.
The word covers two directions that get mixed up. An idempotent API call is one your code can send twice without getting a second result, and an idempotent webhook handler is one that can receive the same event twice without producing a second result.
| Direction | Who retries | The key | Where it is stored |
|---|---|---|---|
| Events you receive (webhooks) | The provider: Stripe retries failed deliveries, and you can resend one from the Dashboard or the CLI | The event’s id | Your processed-events table; Stripe’s guide says to log the event IDs you’ve processed and skip already-logged ones |
| Requests you send (API calls) | You: your code or a worker retries after a timeout or an error | An Idempotency-Key header your code generates, on POST requests | On Stripe’s side, with the first result; keys can be removed once at least 24 hours old, and a key reused after it is pruned makes a new request |
For a Stripe webhook, the idempotency key to dedupe on is the event’s id: Stripe’s webhooks guide says to “Track event IDs to identify duplicate deliveries”. The Event object does carry a request.idempotency_key field, “The idempotency key transmitted during the request, if any”, but it names the API request that caused the event, not the delivery, so it is not the key to dedupe deliveries on (my reading). Keys on the requests you send, with the 24-hour pruning in the table, are the subject of Stripe’s idempotent requests reference.
In a REST API, idempotency is a property of methods. RFC 9110’s idempotent methods section calls a method idempotent “if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request”, and names PUT, DELETE and the safe methods. Stripe’s guide has you accept webhook requests with a POST method, which is not on that list, so the handler has to supply the property itself.
The whole answer fits in one sentence: record the event id and apply the effect in the same database transaction, and treat a duplicate id as work already done.
Two things come before it. A webhook is a message, not a command, and the question of what is a webhook, and what has to be true before it changes your data sits underneath everything here. The signature check also runs before any dedupe; when it rejects real events, the causes are in why Stripe webhook signature checks fail, and the endpoint’s other defenses belong to webhooks security.
On the Production Hardening Sprint, this is deliverable 5.2: we make every webhook handler safe to repeat, including concurrent delivery and its downstream side effects.
What goes wrong without it
Repeated events can duplicate fulfillment or corrupt subscription state if processed unsafely. The same event reaches a handler more than once in three ways, and a handler with no check runs the full effect every time.
| How the duplicate arrives | What a check-free handler does | What the customer sees |
|---|---|---|
| The retry: Stripe tries again after a failed delivery, such as a non-2xx reply, a timeout or no connection | Runs the effect again: a second row, a second credit grant | Two credit top-ups, or two seats for one purchase |
The replay: someone clicks Resend in the Dashboard (up to 15 days after the event) or runs stripe events resend (up to 30 days) | Runs it again, possibly days later | A second welcome email long after sign-up |
| Concurrent delivery: two deliveries of one event reach two workers or two function instances at once | Both read “not seen yet” before either writes, so both apply the effect | A duplicate subscription row, or two provisioning calls to a third-party service |
The retry and the replay are Stripe behavior. The concurrent case is my reading of what happens when two deliveries of one event, say a retry and a slow first attempt, land on a host that runs more than one copy of the handler.
A customer charged twice for one order is the case where the handler itself calls the payment provider: if it creates a charge on every delivery with no idempotency key, each duplicate is a new charge. If a retry created duplicate charges, look there first. A webhook delivered twice can also leave a duplicate subscription row, when the handler inserts a fresh row each time instead of writing against a unique key.
If a webhook fails, what happens next depends on when it fails. A handler that errors out and returns a 500 gets the event again on Stripe’s retry schedule. A handler that returns 200 and then fails has told Stripe the delivery succeeded, so no retry comes; that lost-event case belongs to when a Stripe webhook does not update the database. The rule that one event pays once is also a launch gate in before you turn on payments.
An AI coding workspace I audited saved every chat message through a database function, one copy of which was an upsert keyed on project and sequence number, but the table had no unique constraint on those columns: depending on which schema copy was live, every save errored, or retries and concurrent saves could write duplicate messages. The longer telling is under duplicated logic that drifted apart. It was a chat save, not a payment, but the lesson I take from it carries straight over: an upsert is only as idempotent as the unique constraint under it, and the one-line fix is the constraint.
Don’t count on a test suite to catch it first. In June and July 2026 I audited 21 third-party apps, and only 1 of the 21 was credited with a real test suite; even it skipped sign-up, login and the payment webhook. Those 21 are 11 public apps plus 10 held-out apps audited blind, a selected set rather than a random sample, so the count is not a rate for AI-built apps in general. The checkout side of the same boundary shows up among six ways a vibe-coded checkout leaks money.
How to do it: handle duplicate Stripe events safely
Idempotent webhook handling rests on four pieces: a processed-events table keyed by the event id, an insert-first check inside the transaction that applies the effect, an outbox for side effects the database cannot roll back, and a status code that asks for a retry only when nothing committed.
The Postgres and Stripe behavior below comes from their documentation, quoted where it matters, not from a test I ran.
The processed-events table and the insert-first rule
The processed-events table has the provider’s event id as its primary key. The handler inserts the id first with ON CONFLICT DO NOTHING; if no row was inserted, the event is a duplicate and the handler returns 200 without touching anything else. The constraint, not the code, decides.
Five columns are enough, and the insert is one statement:
create table processed_events (
event_id text primary key,
type text not null,
received_at timestamptz not null default now(),
processed_at timestamptz,
status text not null default 'received'
);
insert into processed_events (event_id, type) values ($1, $2)
on conflict (event_id) do nothing
returning event_id; -- no row back: a duplicate, return 200
PostgreSQL’s INSERT reference describes ON CONFLICT as “an alternative action to raising a unique violation or exclusion constraint violation error”, and DO NOTHING “simply avoids inserting a row as its alternative action”. RETURNING gives back values only “based on each row actually inserted”, so an empty result is the duplicate signal.
Name the conflict target. With on conflict (event_id), Postgres looks for a unique index or constraint on that column, and “If an attempt at inference is unsuccessful, an error is raised.” Leave the target out and DO NOTHING handles “conflicts with all usable constraints (and unique indexes)”, which on a table with no unique constraint means nothing ever conflicts and every duplicate goes in (my reading of the same page).
- 01 Verify the Stripe signature against the raw request body, and reject the request if it fails.
- 02 Open one transaction and insert the event id with
on conflict (event_id) do nothing returning event_id. If no row comes back, commit, return 200 and stop. - 03 Apply the business effect inside the same transaction: the entitlement, the credit, the subscription row.
- 04 Set
processed_atandstatuson the event row, then commit. - 05 Return 200.
Inserting first claims the event before any work starts, so a second delivery stops at step 2 instead of halfway through step 3. The one gap is a crash after the insert and before the effect lands, and the transaction closes it: if the work fails, the event row rolls back with it and the retry finds the id free (my reading).
That only holds if steps 2 to 4 really share one transaction. On Supabase, each supabase-js call goes through PostgREST, where “every request to an API resource runs inside a transaction”, so an insert call followed by two update calls is three transactions. If the effect then fails, the event row is already committed, the retry hits the conflict, and the handler reports success for an event whose effect never landed. The way out is to put those steps in one Postgres function called with rpc(), or to use a direct Postgres connection with begin and commit; the details are in why two Supabase client calls cannot share a transaction.
Key on the event’s id, not on a hash of the body: the id is what Stripe tells you to track, and the Stripe CLI reference’s own example of stripe events resend returns the same event id it was given. Stripe adds one wrinkle: “In some cases, two separate Event objects are generated and sent”, and it says to spot them with “the ID of the object in data.object along with the event.type”. Where that matters, give the table the effect writes to a unique constraint on that pair.
Concurrent delivery: the transaction boundary
Concurrent delivery is settled by the unique constraint: two workers holding the same event both reach the insert, and only one gets a row. Because the effect commits in the same transaction as that row, a crash rolls both back and the retry does the work once.
Take two workers with the same event. Both pass the signature check and both reach the insert. Postgres makes the second one wait: “If a conflicting row has been inserted by an as-yet-uncommitted transaction, the would-be inserter must wait to see if that transaction commits.” If the first commits, the second insert does nothing, returns no row, and that worker answers 200. If the first rolls back, “there is no conflict”, so the second insert goes through and that worker does the work.
The event row protects one event. Two different events that touch the same subscription row, say an update and a cancellation arriving together, need a lock on that row: select ... for update keeps other transactions from locking, modifying or deleting it “until the current transaction ends”. On Supabase the lock belongs inside the same function as the insert, because a lock taken in one PostgREST request ends with that request (my reading).
Locks decide who goes first, not which event is newer. Stripe “doesn’t guarantee the delivery of events in the order that they’re generated” and says not to use created “to determine event order or whether you’ve already processed an event”. Fetching the current object from Stripe instead of trusting the payload comes from a wider question, what is the subscription lifecycle in Stripe, and where each event sits in it.
Side effects outside the database: emails, credits and third-party calls
Side effects outside the database, such as an email or a third-party call, cannot be rolled back, so the handler writes them as intent to an outbox table in the same transaction, and a worker sends each one with its own idempotency key where the provider accepts one.
The outbox is the pattern I recommend: inside the transaction that applies the effect, write one row per outside action (send this email, call this API), and let a worker send them after the commit. If the transaction rolls back, the outbox rows go with it, and nothing is sent for work that never happened. Stripe’s guide points the same way when it asks you to “process incoming events with an asynchronous queue”.
| Side effect | Where the duplicate shows | Idempotency mechanism |
|---|---|---|
| A POST to Stripe (a refund, a credit, an invoice item) | A second refund or charge on the customer’s statement | An Idempotency-Key on the request; Stripe returns the first result for that key, a saved 500 included |
| An email sent through Resend | Two identical emails in the inbox | An idempotency key on POST /emails; Resend checks for the same key already sent in the last 24 hours |
| A call to a provider that takes no key | A second record in the other system | My working rule: mark the row “sending”, make the call, mark it “sent”, and check stale “sending” rows with the provider before any resend |
| A credit or entitlement in your own database | A doubled balance | No outbox: it commits in the same transaction as the event row |
Two details from the providers change how the worker retries. Stripe saves the first result for a key “regardless of whether it succeeds or fails”, so a retry with the same key after a 500 gets the same 500 back; my working rule is to check whether the object exists before trying again under a new key. Resend’s idempotency keys work on POST /emails and POST /emails/batch, are kept for 24 hours, and a key reused with a different payload gets a 409 back. Build each key from the event and the action, such as the event id plus welcome-email, so the worker reuses it on every attempt.
Usage billing has its own version of this, where a retry becomes a second billable event.
Relying on webhooks for mission-critical functionality is reasonable on these terms: the event row, the effect and the outbox rows commit together, and each outside call carries a key (my reading). For what still slips, such as an event lost before it ever reached you, a scheduled job that can keep Stripe subscriptions in sync with the database is the backstop.
Retries and the status code: how to handle webhook retries
A webhook handler returns 200 when the event is recorded or already known, 400 when the signature is invalid, and 500 only when nothing was committed yet. Stripe retries a failed delivery for up to three days in live mode, and the status code decides whether that retry helps.
| What happened in the handler | Status to return | What Stripe does next |
|---|---|---|
| Event id already in the table | 200 | Records a successful delivery |
| Event recorded and effect committed | 200 | Records a successful delivery |
| Signature check failed | 400 | Records a failed delivery (any 4xx is listed as ERR); a non-2xx reply is Stripe’s own example of what leads to a retry |
| Transient failure before anything committed | 500 | Retries for up to three days with exponential back off in live mode, three times over a few hours in a sandbox |
| Failure after the commit, such as the email provider being down | 200 | Records a successful delivery; the outbox worker retries the side effect |
A 400 does not switch retries off: Stripe lists any 4xx as a failed delivery, like a 500, so the 400 only records in the delivery log that the request was refused. Each retry also arrives with “a new signature and timestamp”, so a retried event is checked like a new one.
Stripe’s guide says your endpoint “must quickly return a successful status code (2xx) before any complex logic that could cause a timeout”. My working rule: the transaction holds only the event row, the database effect and the outbox rows, and anything slow, from building a PDF to calling a third party, goes to the outbox worker.
As a webhook reliability checklist, five items cover the receiving end:
- Verify the signature before treating the body as data.
- Dedupe on the event id with a unique constraint.
- Commit the event row and the effect in one transaction.
- Send outside effects from an outbox, each with its own key.
- Watch the endpoint’s Event deliveries tab, which lists events as Delivered, Pending or Failed.
Retrying your own outbound calls is a separate subject, with its own rules on which errors deserve a retry at all; it starts from what is exponential backoff. Hosted webhook relays, Hookdeck among them, are the buy option; the processed-events table and the transaction stay in your own code either way.
How to verify it
Idempotency is verified by trying to double the effect: resend one event from the Stripe CLI, deliver the same freshly signed event several times in parallel, and count what changed. One processed-events row, one subscription change, one email and one outbound call per event passes; anything more is a failure.
Start from an event tied to a real account in your app: complete a sandbox checkout through the app as a test user, and confirm the first delivery made exactly one change, the count going from none to one. An event for a customer your database has never seen proves little, because a broken handler has nothing to duplicate.
- 01 Resend that event with
stripe events resend <event_id> --webhook-endpoint=<endpoint_id>. Pass: the delivery returns 200 and the count stays at 1. Keep the counts from before and after. - 02 Against a test database where that event id is not yet recorded, deliver the same event body five times at once with the script below. Pass: a single row in
processed_eventsand a single copy of each effect. - 03 Turn on a test flag that makes the handler throw once after the event row is inserted and before the effect. Pass: the retry, or a resend, applies the effect exactly once, one and not zero.
- 04 Let the handler commit, then return 500 once. Pass: the Event deliveries tab shows a second delivery attempt for the same event id, that attempt returns 200, and nothing new is written.
- 05 Query the tables. Pass: one processed-events row per event id, and one sent outbox row per side effect.
- 06 Run the script against a local copy of the handler and a test database in CI. Pass: the CI log shows one effect per event.
stripe events resend works only for events created within the last 30 days. Checks 1, 3 and 4 need Stripe to deliver to your test endpoint, and sandbox retries come three times over the course of a few hours, so those three are a recorded manual run rather than a CI job.
The script signs the body afresh. Stripe’s libraries have “a default tolerance of 5 minutes” between the signature’s timestamp and the current time, so a header saved from an old delivery fails the check, and raising the tolerance in the handler to make the test pass weakens the real endpoint. The stripe-node helper generateTestHeaderString exists “to mock webhook events that come from Stripe”. Save the raw body your handler received in the first resend as event.json, set TEST_ENDPOINT_URL to a test endpoint you own, and use the signing secret that endpoint verifies with:
import Stripe from 'stripe';
import { readFileSync } from 'node:fs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const payload = readFileSync('event.json', 'utf8');
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: process.env.STRIPE_WEBHOOK_SECRET });
const post = () => fetch(process.env.TEST_ENDPOINT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, body: payload });
console.log((await Promise.all([post(), post(), post(), post(), post()])).map((r) => r.status));
Save it as concurrent.mjs so the top-level await runs. Expect five successes and one effect. Keep the counts with the event id and the date: a pass covers the events you tested that day, and a new event type or a new side effect needs the run again.
Creating the test endpoint and its signing secret, and sending a first event to it, are part of how to test webhooks. For a paid plan, the same resend becomes the duplicate check on paid access: one repeated checkout event, one entitlement change.
Deliverable 5.2 of the sprint is verified this way: replay and concurrently deliver events; confirm one intended business effect.
Where the sprint does this
On the Production Hardening Sprint, idempotent webhook handling is deliverable 5.2, the work and the checks described in the first section and in the verify section above. It sits after deliverable 5.1, webhook signature verification, which is verified this way: confirm valid events succeed and invalid or altered payloads are rejected. The production readiness report, deliverable 13.1, delivers the result for every scope item, the work completed, and its verification evidence. Hosting, paid tools, and API usage remain in your accounts. We explain any required third-party costs before enabling them. The full list is the sprint’s deliverables, all 123.
Common questions about webhook idempotency
What are common webhook mistakes to avoid?
The common ones are skipping the dedupe, applying the effect outside the transaction that records the event, acknowledging the delivery before the event itself is recorded, trusting the order events arrive in, and never running a replay test. On ordering, Stripe makes no promise that events arrive in the order they were generated, so a handler that assumes it will can apply an older state over a newer one.
Can a POST request be idempotent?
Yes, by design rather than by definition. RFC 9110 lists PUT, DELETE and the safe methods as idempotent and leaves POST out, but it also says a user agent can repeat a POST request automatically “if it knows (through design or configuration) that the request is safe for that resource”. A key or a unique constraint is that design: a webhook receiver dedupes on the event id, and on Stripe’s own API all POST requests accept idempotency keys.
How does Stripe handle idempotency?
Stripe handles it in two directions. For requests you send, it saves the status code and body of the first request made with an idempotency key and returns that same result to later requests with the key. For events it sends, it leaves dedupe to you: log the event ids you have processed and skip any already logged.
What is the idempotency key?
The idempotency key is a unique value the client generates so the server can “recognize subsequent retries of the same request”. Stripe suggests V4 UUIDs or another random string, up to 255 characters, and asks you to keep sensitive data such as email addresses out of it. On the receiving side of a webhook, the event id plays the same role (my reading).
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase