Webhook providers such as Stripe and GitHub deliver events on an at-least-once basis: they retry a delivery until your endpoint acknowledges it, so the same event can arrive more than once. Exactly-once delivery is not achievable in practice, so the reliable fix is to make your handler idempotent, meaning processing the same event twice produces the same result as processing it once.
The database is where you can enforce that guarantee. If your handler records the event ID in a table with a unique constraint, the database answers the question "have I already processed this event?" in the same atomic step as your business update. You need to store processed events, commit the dedup record and the business change together, handle retries that arrive out of order, and respond to the provider so it stops retrying without triggering duplicate work.
Why duplicate webhook deliveries are normal
Webhook providers do not promise exactly-once delivery. If your endpoint times out, returns a 5xx, or the network drops the response, the provider retries the same delivery. The Hookdeck guide to webhook idempotency says it plainly: duplicate events are inevitable, so you must build handlers that tolerate them.
Some handlers are already safe to run twice. Setting a status flag, updating a projection, or writing a value with replace semantics produces the same end state on every run. Those operations need no extra machinery. The handlers that need a dedup record are the ones with irreversible or additive effects: charging a card, sending an email, decrementing inventory, appending a row. For those, running twice changes the outcome, and the retry becomes a real bug rather than wasted work.
Record the event ID before you process the event
The core pattern is a table with a unique constraint on the event identifier, and the handler inserts that identifier before doing any business work. If the insert succeeds, this delivery is the first one and processing proceeds. If the insert finds an existing row, the handler skips processing and returns the stored outcome.
Make the insert the one atomic step that claims the event. A separate SELECT-then-INSERT check has a race condition: two concurrent retries can both read "not present" and both proceed to process the event. The idempotency patterns writeup explains the fix: use INSERT ... ON CONFLICT DO NOTHING, and treat an insert that returns no row as "someone already claimed this key." Postgres expresses this as:
CREATE TABLE processed_events (
event_id text PRIMARY KEY,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO processed_events (event_id, payload)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING;
The provider determines which event ID you store. GitHub sends an X-GitHub-Delivery header that is unique per delivery and doubles as replay protection, and GitHub’s webhook best practices treats it as the delivery identifier. Stripe events carry their own event ID. If you consume events from a message queue, use the natural business key such as a payment ID rather than a Kafka offset, because offsets are not stable across consumer rebalances.
For providers that do not include an event ID, hash the payload and store the hash under a unique index. Some providers include a timestamp that changes on each delivery, so you may need to strip that field before hashing, otherwise every retry looks like a new event. SHA-256 collisions are astronomically unlikely, but if a duplicate has severe consequences, add a secondary check on the original event ID when one exists.
Commit the dedup record and the business change together
Recording the event ID is only half the guarantee. The dedup insert and the business update must commit in the same database transaction. If the transaction commits, both the dedup record and the business effect become visible together. If it rolls back, neither becomes visible, and the next retry can safely try again. This is the idempotent consumer pattern applied to webhooks: the record of the work and the work itself share one fate.
Store the response with the dedup record so a retry can return the original result instead of reprocessing. Stripe does exactly this in its API: it saves the status code and body of the first request after endpoint execution begins, and retries with the same key return that saved response, including saved 500 errors. Caching a 5xx matters because a transient failure on the first attempt should not be replayed as a permanent success. Cache the response only for success and 4xx outcomes, and let a later retry try again after a 5xx.
When a retry arrives while the original request is still in flight, the unique constraint blocks the second claim. The idempotency patterns guide recommends returning 409 Conflict so the caller polls for the result rather than blocking. Returning 409 assumes the caller can poll; a client that blocks waiting on the original request can turn the 409 into a retry storm. A reaper job should delete stuck PENDING keys older than a timeout, such as 60 seconds, and expired keys, so a crashed worker does not block the key forever. The unique constraint on the key column prevents duplicate processing under concurrent retries.
Scope idempotency keys to a tenant or account, because the same key generated by two different customers should not collide. If a client replays a key with a different body, reject the request with a 422, the way Stripe compares incoming parameters to the original request and errors when they differ.
The transaction covers only the database. External side effects such as emails, SMS, or payment gateway calls happen outside it. If the handler runs again after a crash, it can duplicate them unless the external call carries an idempotency key of its own or the handler records intent in the database before calling out.
Handle retries that arrive out of order
Providers do not guarantee delivery order, and Stripe explicitly documents that event ordering is not guaranteed. A retry of an older event can arrive after a newer one, and a naive handler that assumes "the last event wins" will corrupt state. The webhook architecture guide lists workable strategies. Carry a resource version number or monotonic sequence in the event, or compare occurred_at timestamps only when you trust the sender’s clocks. The guide also suggests fetching the current resource state and reconciling, ignoring stale transitions, and serializing processing per aggregate.
A concrete example: an "order updated" webhook arrives before the "order created" webhook for the same order. If your handler blindly applies the update to a row that does not exist yet, it either errors or silently drops the change. If the events carry an order version number, the handler can store the update and apply it when the create arrives, or it can reject the update as stale and fetch the current order state from your API to reconcile. The version number approach does not depend on the provider’s delivery order, which is exactly why it beats assuming the order of arrival.
Respond so the provider stops retrying
The response your handler returns tells the provider whether to keep delivering. A 2xx acknowledges the event, and the provider treats the delivery as done. Anything else, including a 4xx or 5xx, prompts another retry. GitHub recommends that your endpoint respond with 2XX within 10 seconds on GitHub.com and 30 seconds on GitHub Enterprise Server of receiving a delivery.
The handler should accept the event durably, return a 2xx quickly, and process asynchronously. "Durably" means the handler has written the event to a place that survives a crash, either the dedup table or a queue, before it responds. Once the handler has acknowledged, a duplicate delivery of the same event should also get a 2xx with the stored result, because returning an error for a duplicate of a finished event makes the provider keep retrying work that is already done.
Gateway-level deduplication, where a proxy or API gateway filters duplicate deliveries before they reach your code, is not a substitute for an idempotent handler. The check and the persistence are not atomic, so a crash between them can let a duplicate through. Your handler must remain safe to call twice even when the gateway does its job.
Dedup records also need a retention policy. A 7-day window covers Stripe’s 3-day retry period and GitHub’s 72-hour window, but some enterprise systems retry for 30 days, so match the window to the providers you use. Longer retention costs storage, and the idempotency patterns guide notes that storage grows with request volume, which is the trade-off you accept for replay safety.