Stripe Webhook Idempotency Is Not Just Deduplicating Event IDs
Seeing an event and successfully applying an event are two different states.
Contents
A common webhook pattern looks sensible:
- receive event;
- insert the provider's event ID into a database table with a unique constraint;
- if the ID already exists, return success;
- otherwise process the event.
This prevents a duplicate delivery from applying the same business effect twice.
It can also introduce a subtle failure mode.
The problem is simple:
Receiving an event is not the same as successfully processing it.
That distinction matters for Stripe webhook idempotency and for webhook systems generally.
The failure window
Imagine this sequence.
A Stripe event arrives:
evt_123
Your server inserts:
evt_123 → received
into an event ledger.
Then application processing begins.
Perhaps the system needs to:
- update subscription state;
- update entitlements;
- persist a period boundary.
During that work, the database experiences a temporary failure.
The endpoint correctly returns a non-2xx response so Stripe can retry.
Later, Stripe sends evt_123 again.
If your duplicate logic says:
Event ID already exists. Return 200.
the retry is lost.
The system remembers that it saw the event.
It has forgotten that it never successfully applied it.
A better event lifecycle
A useful event ledger distinguishes states conceptually similar to:
received
processing
processed
failed_recoverable
failed_permanent
The exact labels are less important than the distinction.
A duplicate event should be ignored only when the system knows the business effect was already completed.
Conceptually:
if event is already processed:
return 200
if event was seen but remains unprocessed:
safely retry processing
That immediately raises another question.
What if the original request and retry overlap?
Concurrency is part of idempotency
Two copies of the same event can arrive close together.
Both can observe:
processed = false
and both can begin work.
The event ledger therefore needs a concurrency strategy.
Depending on the database and architecture, this might use:
- row-level locking;
- an atomic state claim;
- a transaction;
- compare-and-set;
- advisory locks.
The important requirement is:
Only one worker should be able to commit the business effect for the event at a time.
An in-memory mutex is not sufficient for a horizontally scaled application because requests can reach different instances.
Business writes should also be idempotent
Even a perfect event ledger does not remove every failure window.
Consider:
- business effect commits;
- process crashes;
- event ledger does not get marked
processed; - Stripe retries.
The retry now needs to discover that the business effect already happened.
That is why important business operations should often have their own idempotency.
For example:
- one local subscription per Stripe subscription ID;
- one invoice record per Stripe invoice ID;
- one event-derived transition per stable external identifier.
Database uniqueness can be an extremely useful defence.
Transport-level idempotency and domain-level idempotency support each other.
Transactions help when the boundary is local
If all relevant work occurs in one database, the cleanest pattern may be:
BEGIN
claim event
apply local business changes
mark event processed
COMMIT
If the transaction fails, none of those states become partially durable.
Real systems are not always that simple.
A handler may:
- call external APIs;
- send messages;
- trigger asynchronous jobs.
Once external side effects are involved, you are dealing with a distributed workflow.
The design needs an explicit retry strategy rather than assuming the entire operation is atomic.
Permanent and recoverable failures are different
Not every failure should trigger endless retries.
A useful handler distinguishes:
Recoverable failure
Examples:
- database temporarily unavailable;
- downstream service times out;
- transaction deadlock;
- temporary infrastructure error.
The system should preserve the ability to retry.
Permanent or intentionally ignored condition
Examples may include:
- unsupported event type;
- malformed internal mapping that requires manual intervention;
- event for an object deliberately outside scope.
The correct behaviour depends on the application.
The important thing is to record why the event became terminal instead of accidentally marking it successful.
Idempotency is a behavioural guarantee
A robust definition is not:
We store unique event IDs.
It is closer to:
Re-delivering the same provider event — including after partial failure — cannot create an incorrect duplicate business effect, and a recoverable failed attempt remains capable of completing later.
That statement can be tested.
For example:
Given an event is recorded
And processing fails before completion
When the provider retries the same event
Then processing is attempted again
And the business effect occurs exactly once
And the event ends in processed state
That is much more useful than merely checking the database rejected a duplicate ID.
This is why acceptance tests matter
Coding agents can implement an idempotency pattern very quickly.
The difficult part is specifying the property we actually want.
That is one example of the broader argument in How Do You Know AI-Generated Software Is Actually Correct?:
We should test the invariant, not the implementation shape.
For webhook processing, the invariant is not:
there is a UNIQUE(event_id) index
It is:
safe retry + exactly-once business outcome where required
Those are different things.
The distinction is small enough to miss in code review and important enough to break billing state.
That is exactly the kind of production edge case that should not need to be rediscovered from scratch.

