How to Handle Webhook Retries Without Repeating the Action
Prevent duplicate webhook actions with a durable inbox, atomic event claims, safe retries, and a recovery path for uncertain external outcomes.

In this piece
Why it matters
An automation can appear successful while duplicating an external action or losing unfinished work.
A webhook retry can run your automation twice because the sender does not always know whether the first request succeeded. Preventing duplicate actions requires a durable record of the event and, for external effects, an operation that can safely be retried or checked afterward.
A delay is not a reliable duplicate filter. Neither is a variable stored in one running process. The workflow needs to remember what happened across concurrent requests, crashes, and later redeliveries.
Why a successful action can look like a failure
Imagine an order service sends an event to your automation. Your server records the order, but the network connection closes before the sender receives the response. The sender sees an uncertain result and may try again.
The second request is not necessarily a second order. It may be another delivery of the same event. Stripe's webhook documentation explicitly discusses duplicate events and event ordering. Other providers have their own delivery contracts, so check the sender's documentation instead of assuming all webhooks retry alike.
GitHub, for example, documents redelivery and delivery identifiers. A design that depends on automatic retries from every provider can quietly lose work.
Separate the event from the action
There are at least three identities in a typical workflow: the delivery attempt, the underlying event, and the business action you want to perform. Confusing them makes duplicate handling harder.
Suppose an event means order order_42 is ready for fulfillment. Two deliveries may represent that one event. Two distinct events may also describe the same business transition. A useful design records the provider's event identifier and gives the intended fulfillment operation its own stable identity.
Do not deduplicate only by a timestamp. Different events can share a time, and the same event can arrive later. Do not deduplicate every event for an order forever, either. The order may legitimately need a later cancellation or address update.
Verify the sender before trusting the identifier
Validate the webhook using the provider's documented signature procedure. Keep the secret on the server, and preserve the request bytes when the signature method requires the raw body.
An event identifier is useful for tracking, but it is not proof of origin. An unauthenticated request should not be able to reserve a guessed identifier and block a later legitimate event.
Subscribe only to the event types the workflow needs. Validate the type and payload before deciding which operation to create. These checks reduce confusion when a provider introduces a new event shape or when unrelated events reach the same endpoint.
Accept work durably before acknowledging it
For a workflow that processes events in the background, a useful sequence is:
- Verify and validate the request.
- Store the event and a pending work item durably.
- Return a successful acknowledgment after that storage commits.
- Let a worker perform the action and record its result.
The acknowledgment means your system has accepted responsibility. If you respond successfully before storing anything, a crash can erase the event after the sender believes delivery succeeded.
If storage fails, use the provider's documented failure response and recovery process. Some senders retry, while others require redelivery or reconciliation. Once you have acknowledged the request, your own monitoring must detect stuck background work.
Make duplicate claims atomic
Two requests can arrive at nearly the same time. A sequence that checks for an event and then inserts it can let both requests pass the check before either inserts.
A database uniqueness constraint can arbitrate that race. This simplified PostgreSQL sketch illustrates the idea:
CREATE TABLE webhook_inbox (
provider text NOT NULL,
event_id text NOT NULL,
state text NOT NULL DEFAULT 'pending',
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);
INSERT INTO webhook_inbox (provider, event_id)
VALUES ('example-provider', 'event_42')
ON CONFLICT DO NOTHING
RETURNING event_id;
If the insert returns a row, that statement inserted the event. If it returns no row, an entry already exists. PostgreSQL's INSERT documentation explains conflict handling and returned rows.
This is a teaching sketch, not a complete worker implementation. A real inbox needs enough payload information to resume work, an atomic worker claim, failure records, and a recovery policy. If queuing is separate from the inbox, make their relationship durable so a crash cannot leave an accepted event with no work scheduled.
The hardest failure happens after the external action
Now imagine the worker sends an email and crashes before recording completion. The database still says pending or running. Retrying may send the email again.
A database transaction cannot undo an email already accepted by another service. This is why an event ledger alone does not guarantee one external effect.
Where the destination supports idempotency keys, use a stable key for the intended operation and reuse it for retries. Idempotency means repeating the request has the intended single effect under the service's contract. Check retention periods, parameter rules, and error behavior.
Stripe's idempotent-request reference distinguishes API versions and documents those details. Do not generalize its guarantees to an unrelated email, ticketing, or automation service.
Where no such feature exists, look for a way to query the destination using your operation identifier before retrying. If you cannot determine whether the action happened, mark the outcome uncertain and route it for reconciliation. Blind retries are a poor default for irreversible actions.
Recover abandoned work without racing a live worker
Workers need a claim mechanism so two processes do not perform the same pending task simultaneously. A lease can allow another worker to recover a task after the original worker stops reporting progress.
Lease expiry still needs care. A slow worker may continue after its lease expires. The external operation must remain safe under that overlap, or the system needs a stronger coordination mechanism. Marking a row running is not enough if nothing can recover it after a crash.
Record attempts and the last error without storing unnecessary secrets. Alert on work that remains pending or uncertain beyond a sensible period. Keep records long enough for the provider's redelivery window and your own recovery needs.
Check failures that ordinary demos skip
Before relying on an automation, replay the same event, deliver two copies concurrently, restart the worker after acceptance, and simulate a destination timeout after the action might have succeeded. Also try events arriving out of order.
Use a sandbox destination for these checks. The aim is to learn what the workflow does under uncertainty, not to create real duplicate purchases or messages.
For code-based automation, review how external text reaches execution as well. Our GitHub Actions guide covers a different boundary where treating input as trusted instructions causes trouble.
A dependable webhook workflow can explain both what it has accepted and what it has actually completed. That record, together with safe external operations and a recovery path, is what makes retries manageable.
Sources
- Receive Stripe events in your webhook endpoint, Stripe (docs.stripe.com)
- Best practices for using webhooks, GitHub (docs.github.com)
- INSERT and ON CONFLICT, PostgreSQL Global Development Group (postgresql.org)
- Idempotent requests, Stripe (docs.stripe.com)
Spot an error? Tell us. See how we handle sources and corrections in our editorial standards.
Found this useful? Get The Weekly Desk, one concise email a week.