A webhook is a notification, not a promise that your handler will run once. If your application treats every delivery as new work, a retry can repeat an email, an entitlement change, or a provisioning job.
Stripe explicitly documents that webhook endpoints can receive the same event more than once. That does not mean Stripe charges a customer again whenever it repeats a notification. Receiving an event and initiating a payment are different operations. This article is about what your application does after receiving the notification.
The useful design question is simple: if this work runs again, what happens?
A receipt is not a completed job
Consider an illustrative SaaS application. A verified billing event arrives, and the application needs to update a local entitlement record. The handler checks whether it has seen the event, changes the entitlement, records completion, and responds.
That sounds reasonable until the process stops between two of those steps.
If it changes the entitlement and crashes before recording completion, a later attempt may repeat the change. If it records completion first and crashes before changing the entitlement, a later attempt may skip work that never happened.
The problem is the gap between the effect and the record of that effect. Changing the order does not remove the gap.
For a local database change, I would start by putting the relevant state change and its completion record in the same database transaction. PostgreSQL's transaction tutorial describes the all-or-nothing boundary: the transaction's updates become a unit. This only helps with work inside that transaction. It does not turn an unrelated email API into part of your database commit.
Give delivery and processing separate jobs
Stripe's webhook guidance recommends checking signatures, returning a successful response quickly, and handling events asynchronously. Signature verification needs the raw request body, so do not let middleware transform it before verification.
One design worth evaluating is a durable inbox. The receiving endpoint verifies the event and stores a receipt that a worker can process later. It acknowledges the notification after durable acceptance, rather than waiting for a slow business workflow.
That is a suggested architecture, not a guarantee supplied by Stripe. You still need a working path from the inbox to a worker. If a queue is used, account for a crash after storing the receipt but before publishing the queue message. A durable row that nobody revisits is not a completed handoff.
For a small system, a worker that claims pending inbox rows may be easier to reason about than coordinating two separate writes. Choose the mechanism your team can operate and recover.
A check before an insert is not enough
Two requests can arrive close together. Both can ask whether an event exists before either has inserted it. An application-level check alone leaves that race open.
Use the database's constraint mechanism for the receipt identity. PostgreSQL's unique-constraint documentation explains uniqueness across a column or group of columns. Make the identifying fields non-null, and choose a key scoped to the provider and account context your application actually handles.
A duplicate receipt should not create a second inbox job. But the original job may still be pending or failed. Deduplicating delivery is not permission to forget unfinished processing.
Worker concurrency is another boundary. Decide how a worker claims a row, how another worker knows it is claimed, and how abandoned claims become eligible again. Those details depend on your database and queue. A boolean called processed is not a recovery design by itself.
The event ID is only one kind of identity
Stripe recommends tracking processed event IDs to identify duplicate deliveries. It also notes that separate Event objects can sometimes describe duplicates; its guidance points to the object ID and event type for identifying those cases.
Do not turn that into a universal rule that every event concerning the same object is redundant. Different events can represent legitimate state changes. Define what one business operation means before choosing its deduplication key.
For the entitlement example, distinguish setting a record to a known state from appending another grant. They have different repeat behavior. Also, Stripe does not guarantee event delivery order. An older notification must not blindly overwrite a newer state just because it arrived later. Where needed, use the provider's current object state and your application's transition rules rather than assuming arrival order is chronology.
Test the uncomfortable interruption
Before calling the handler ready, deliberately test duplicate delivery, two concurrent workers, and a crash around the commit boundary. Include a failed job that is retried after the receiving endpoint has already acknowledged it.
If the effect is external, such as sending an email, test that boundary separately. Check whether the destination supports an idempotency key, what it covers, and how long it lasts. Without a shared transaction or a suitable destination guarantee, recording a successful send and making the send remain separate actions. Do not label that exactly-once processing.
The goal is not to make retries disappear. It is to make repeated delivery and interrupted work ordinary cases your system can explain and recover from. Keep a durable receipt, keep unfinished work visible, and make each effect safe to retry within its actual boundary.
Cover photo by Markus Spiske, free to use under the Unsplash License.
I write about startups and AI on StartupWiki.
