← All articles

Webhook Retries and Replay: How to Receive Webhooks Without Losing Events

Webhooker Team Updated 14 min read
Flat illustration of a timeline with a gap in the middle: on the left, short retry arcs bounce against a closed endpoint and fade out, while the missed events drop into a storage shelf below; on the right, the blue Webhooker pulse node lifts the stored events from the shelf and sends them in one long arc to the reopened endpoint.

Webhook retries and replay cover two different failures. Retries are automatic and short-lived: the sender, or a gateway in front of your service, resends a failed delivery on a schedule that lasts minutes to a few days. Replay is a deliberate resend of stored events, one at a time or in bulk, after the retry schedule has ended. To receive webhooks reliably you need both, plus a handler that acknowledges quickly and tolerates duplicates.

This page is about the receiving side: what providers do when your endpoint fails, which status codes to return, where retries stop helping, and what makes a replay safe. The math behind retry schedules is in our piece on exponential backoff, jitter and circuit breakers and is not repeated here.

What is the difference between webhook retries and replay?

A retry is the same delivery attempted again because the last attempt failed. The sender decides when it happens and when it stops, and you cannot extend it.

A replay is an operator or a script choosing to send an event again. It can happen an hour or three weeks after the original delivery, and it can target events that were marked as delivered. It only works if someone kept the event.

Table: webhook retries and webhook replay compared by trigger, the failure each one fixes, and owner.

RetriesReplay
TriggerA failed attemptA person or a script
What it fixesShort failures you never noticeLong outages, and bugs that answered 200 while doing the wrong thing
Who owns itThe sender or gatewayWhoever stores the raw events, which is often nobody

How do Stripe, GitHub, Shopify and other providers retry webhooks?

Each row below was checked against the provider’s own documentation on 21 September 2026.

Table: automatic retry policy, retry window and manual redelivery options for six webhook providers.

ProviderAutomatic retriesWindowManual redelivery
Stripe (live mode)Exponential backoff, attempt count not publishedUp to 3 daysResend from the Dashboard for 15 days, from the CLI for 30 days
GitHubNone. A response slower than 10 seconds counts as failedNoneRedeliver deliveries from the past 3 days, by hand or through the REST API
ShopifyUp to 8 retries. Must answer within 5 seconds4 hoursNone documented. After multiple failures in a 24-hour period the subscription is removed
Resend8 attempts: at once, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 hAbout 27.5 hoursReplay button for failed and succeeded messages. An endpoint that keeps failing is disabled
HubSpotUp to 10 retries with randomized delays. Retries on any 4xx or 5xx, and on responses slower than 5 seconds24 hoursNone documented
Twilio1 retry by default, and only on a failed connection. Configurable from 0 to 5 retries and by failure typeNot publishedNone documented on that page

You cannot design around “the provider will retry”. GitHub never does, and Twilio by default retries once, and only when it cannot connect.

The windows are also shorter than the incidents that matter. An outage that starts at 02:00 and is fixed when people arrive at work is already past Shopify’s four hours. Worse, a failing endpoint can cost you the subscription itself, because Shopify removes it and Resend disables the endpoint.

The Stripe row has more detail than fits in a table, including how to list undelivered events through the API. That is in the Stripe webhooks guide.

Which HTTP status codes should trigger a webhook retry?

A sender should retry on 5xx, 429, 408, timeouts and connection errors. These describe the state of the receiver, so a later attempt can succeed. It should not retry most other 4xx. A 400, 401, 404 or 422 describes the request, and the same bytes will fail the same way every time.

That is the rule a well-built gateway follows. Providers are blunter. Stripe and Shopify treat anything outside 2xx as failed, redirects included, and HubSpot says outright that it retries any 4xx or 5xx. Answering 400 to a payload you dislike does not stop those retries.

Should your handler return 500 when processing fails?

Only when you have not safely stored the event. A 500 asks the sender to hold the event for you, and that only makes sense while you do not hold it yourself.

Return codes that work across providers:

This leads to the pattern providers recommend: acknowledge first, process later. Stripe’s docs tell you to return 2xx “before any complex logic that might cause a timeout”. Shopify and HubSpot give you five seconds, GitHub ten.

import express from "express";

const app = express();

app.post(
  "/webhooks/billing",
  express.raw({ type: "application/json" }),
  async (request, response) => {
    const rawBody = request.body;

    if (!isSignatureValid(rawBody, request.headers)) {
      return response.status(401).send("invalid signature");
    }

    try {
      await eventQueue.enqueue({
        rawBody: rawBody.toString("base64"),
        headers: request.headers,
        receivedAt: new Date().toISOString(),
      });
    } catch (enqueueError) {
      return response.status(503).set("Retry-After", "30").send("try again");
    }

    return response.status(200).send("ok");
  }
);

The handler verifies, stores and answers. Business logic runs in a worker that reads from the queue. If the worker fails, the retry is now yours and not the provider’s. That is the point, because you control that schedule. What gets stored is the raw body and the headers, not a parsed object, and the replay section below depends on that.

Why do retries alone still lose events?

Retries assume the failure is short and visible. Two ordinary situations break that assumption.

The first is an outage that outlasts the window. A certificate expires on Saturday. By the time someone fixes it, Shopify’s four hours and HubSpot’s 24 hours are long gone, and GitHub never tried a second time. The provider considers the matter closed.

The second is a handler that answers 200 and does the wrong thing. A release ships a bug that misreads the amount field, or drops a new event type on the floor, and returns 200 either way. From the sender’s side every delivery succeeded, so there is nothing to retry. You find out four days later when the numbers do not match. No retry policy of any length helps, because no attempt ever failed.

The ack-fast pattern adds a quieter third case. Once you return 200, the provider is done. If your worker then fails for longer than your own retry schedule, the event exists only where you stored it.

All three end the same way: the event is needed again after every automatic mechanism has finished. That is what replay is for.

What does webhook replay require?

Webhook replay needs four things: the raw event as it arrived, enough retention, consumers that tolerate duplicates, and a plan for signatures that have aged.

The raw body and original headers, stored at ingest

Parsed JSON is not enough, and neither is a log line. Re-serialized JSON changes key order and whitespace, which breaks any signature check downstream. Headers carry the event type, the delivery id and the signature. Our piece on dead letter queues covers what a stored failure should contain and how to query it.

Retention longer than your slowest detection

If it can take a week to notice a mis-processing bug, three days of stored events is not enough.

Idempotent consumers

A replay delivers events your system may have fully or partly processed already. That is at-least-once delivery used on purpose. The consumer has to recognise an event it has seen and do nothing. The mechanics are in webhook idempotency keys. The mis-processing bug adds a twist: those events are already marked as seen, so the replay needs a way to force reprocessing, such as clearing the dedupe records for that window.

A plan for signatures and timestamps

Stripe signs the timestamp together with the body, and its libraries reject anything older than five minutes by default. A Stripe payload stored on Monday and replayed on Thursday with its original Stripe-Signature header has a valid signature and a stale timestamp, so a standard verifier rejects it. Stripe’s own retries avoid this because, as its docs put it, Stripe generates “a new signature and timestamp for the new delivery attempt”.

There are two clean ways out. Verify the provider signature once at ingest, record the result, and have internal replays skip the provider check because they arrive over a channel you trust. Or put a component in front that verifies the provider once and signs every outgoing delivery itself with a fresh timestamp. The wrong way out is widening the tolerance to days or switching verification off during recovery.

Is webhook replay the same as a replay attack?

No. A replay attack is a third party resending a captured, validly signed request to make your system act twice. The defence is a signed timestamp with a short tolerance window plus delivery-id dedupe. Operator replay is an authenticated person resending an event that was already verified and stored. The two happen on opposite sides of your trust boundary, which is why the timestamp check that blocks the attack should stay on while you replay. The security side is covered in webhook replay attacks and timestamp tolerance.

How do you run a bulk replay after an incident?

  1. Scope the replay. Pick the destination and the time window, and count the events.
  2. Confirm the fix with one event. Replay a single delivery, then check the side effect in your own system as well as the status code.
  3. Check the consumer’s dedupe. A second delivery of an event you know was processed must be a no-op.
  4. Mind the rate. Eight hours of traffic delivered in one minute is a load test. Replay in slices by time if you cannot throttle.
  5. Expect events out of order. Replayed events arrive after newer ones, and a handler that overwrites state from the payload will roll records back. See why webhooks arrive out of order.
  6. Reconcile afterwards. Compare four counts: events in the window, replayed, succeeded, still failing.

Your endpoint was down for 8 hours. What now?

A short runbook, assuming providers deliver straight to your service.

  1. Fix the endpoint and prove it with a test event from each provider. Check that subscriptions still exist, because Shopify may have removed yours and Resend may have disabled the endpoint.
  2. Sort providers by window. Eight hours in, Stripe (3 days), Resend (about 27.5 hours) and HubSpot (24 hours) are still retrying. Those events arrive without your help, so expect a burst.
  3. Redeliver where the provider allows it. GitHub never retried, so redeliver the failed deliveries through its REST API within three days.
  4. Backfill what is gone. Shopify’s four-hour window closed halfway through the outage. Pull the affected records from the provider’s API for that window and run them through the same code path as the webhook, with the same dedupe.
  5. Watch for duplicates. A manual resend at Stripe does not cancel the retries already scheduled, so some events land twice.
  6. Write down the detection gap. Eight hours is an alerting problem before it is a delivery problem.

Step 4 is the expensive one, and it differs for every provider. That cost is the argument for storing events yourself, or for putting something in front that does.

How does a webhook gateway handle retries and replay?

We build Webhooker, so this section describes our own product. The general shape is the same for any webhook gateway.

A gateway splits the path in two. The provider sends to an ingest URL such as https://app.webhooker.eu/in/<token>. The gateway verifies the signature, stores the raw body and headers, and answers 200 before your service is involved. A bad signature gets 401 and is never delivered. To the provider your endpoint is always up and fast, so its retry window stops mattering.

Delivery to your service is then the gateway’s job. Webhooker makes six attempts over about five hours with exponential backoff and jitter. It retries 5xx, 429, 408, timeouts and connection errors, and treats other 4xx responses as permanent. A per-destination circuit breaker pauses a destination that is clearly down, so it does not slow the others. Deliveries that run out of attempts go to a dead-letter queue with per-attempt history, and you can replay one event or a whole filtered window. The retries and replay docs show the schedule.

The signature problem is handled by re-signing. Each delivery, whether a first attempt, a retry or a manual replay, is signed at send time with a fresh timestamp. Your service verifies one scheme with a five-minute tolerance, and an event replayed ten days later still passes. Every delivery of the same event carries the same X-Webhooker-Event-Id, a stable key for dedupe.

Run the eight-hour outage again with this in place. Providers saw 200 throughout. Older events exhausted their attempts and sit in the dead-letter queue, and newer ones deliver on schedule once your endpoint answers. Recovery is a filter by destination and time and one bulk replay, the same for Shopify as for Stripe.

There are limits. Events are kept for 14, 30 or 90 days depending on the plan and deleted after that, so a replay cannot reach further back. Six attempts is fewer than some gateways make. And without idempotent handlers, replay is still dangerous.

To try it, create a free source, register its ingest URL as a second endpoint at your provider, and stop your test service for a while to see what the history records.

Frequently asked questions

How do you handle webhook retries?

On the receiving side, you mostly handle their consequences. Return 2xx fast so that retries are rare, return 503 only when you failed to store the event, and make the consumer idempotent so a retried delivery is a no-op. Do not assume a schedule: GitHub makes no automatic retries, Shopify stops after 4 hours and Stripe continues for up to 3 days.

Should we retry on 500?

If you are the sender, yes. A 5xx describes the receiver’s state, so retry with exponential backoff and a cap on attempts. Also retry 429, 408, timeouts and connection errors, and do not retry most other 4xx. If you are the receiver, return 500 or 503 only when you could not store the event, because that response is what keeps the event alive at the sender.

How do you handle webhook failures?

In layers. Automatic retries absorb short failures. A dead-letter queue keeps deliveries that ran out of attempts, and replay sends them again once the cause is fixed. Alerts on failure rate keep detection time shorter than the shortest retry window you depend on.

What is webhook replay?

Webhook replay is sending a stored webhook event to its destination again on purpose, after the original delivery failed or was processed incorrectly. It needs the raw body and headers stored at ingest, enough retention to cover the time it takes to notice a problem, and consumers that deduplicate by event id. It is unrelated to a replay attack, where a third party resends a captured request.

How do I reliably receive webhooks?

Verify the signature on the raw body, store the event durably, and return 200 within a couple of seconds. Process asynchronously from your own queue, deduplicate on a stable event id, and keep raw events long enough to replay them. A gateway covers the verify, store, retry and replay parts. The idempotent consumer stays your job.