Blog

Reliable Webhook Delivery: How to Never Miss a Webhook

Why webhooks get lost, how long Stripe, Shopify, GitHub and Slack really retry, and the five-stage pipeline (receive, queue, process, retry, dead-letter) plus monitoring and reconciliation that makes delivery reliable.

15 min read
Karolis Rusenas
On this page

Reliable webhook delivery means every event a provider sends is eventually processed exactly once by your system, even when your endpoint is slow, deploying or down. Providers don't guarantee that. They retry for a window that ranges from zero (GitHub) to seven days (WhatsApp Cloud API), with Stripe at three days and Shopify at four hours, and then they stop. Reliability is something the receiving side builds out of five stages: receive fast, queue durably, process idempotently, retry with backoff, and dead-letter what still fails. Monitoring and reconciliation wrap around all five.

The short version

  1. Acknowledge fast. Verify the signature, store the raw event, return 200. Do the work later.
  2. Process idempotently. Record each event id with a unique constraint; skip duplicates.
  3. Retry your own failures from durable storage with exponential backoff and a deadline.
  4. Dead-letter, then replay. Park events that keep failing; replay them after the fix.
  5. Watch and reconcile. Alert on failures and on silence, and periodically ask the provider's API for anything you never received.

Why webhooks get lost

Every lost webhook falls into one of six buckets. The first four are your side; the last two are the provider's.

FailureWhat happensDefence
Endpoint down or deployingDelivery fails; provider retries for its window, then gives upBuffer in front; long retries
Handler too slowProvider times out (Slack 3 s, Shopify 5 s, GitHub 10 s) and counts a failure, even if you finish the workAcknowledge first, process async
Handler crashes after 200Provider thinks it is delivered; your work never happenedStore before acknowledging; retry from storage
Bug in processingEvery event of a type fails the same wayDead-letter and replay after the fix
Provider stops retryingOutage outlasts the retry window; events are droppedYour own durable retries; reconciliation
Provider never sendsBugs, disabled or deleted subscriptions, filtering mistakesSilence alerts; reconciliation

The last row is the uncomfortable one: Shopify's documentation states plainly that delivery "isn't always guaranteed". A receiver that only listens can never be fully reliable. It also has to ask.

How long providers really retry

Before you design anything, know how much slack each sender gives you. All of these are from the providers' own documentation, checked in November 2026.

ProviderResponse timeoutAutomatic retriesWhen it gives up
StripeNot publishedUp to 3 days, exponential backoff (live mode); 3 times over a few hours in a sandboxEvent is no longer retried; resend by hand for 15 days in the Dashboard, 30 days with the CLI
Shopify1 s to connect, 5 s total8 retries over 4 hoursStops; repeatedly failing admin or Admin API subscriptions are deleted
GitHub10 sNoneRedeliver by hand or API from the last 3 days
Slack Events API3 s3 retries: almost immediately, after 1 minute, after 5 minutesSubscriptions are temporarily disabled above 95% failures in 60 minutes
WhatsApp Cloud APINot publishedWith decreasing frequency for up to 7 daysDropped

Two things stand out. The windows are short: half of these give you minutes or hours, not days. And they are all different, so an outage that one provider survives is data loss for another. The fix is to stop depending on each sender's retry policy and own the retry window yourself, which the pipeline below does.

At-least-once is the best anyone offers

Without an acknowledgement, a sender can't tell these four cases apart:

  1. The request was lost on the way.
  2. It arrived and was processed, but the 200 was lost on the way back.
  3. The receiver crashed before processing it.
  4. The network is just slow.

Retrying fixes 1 and 3, and turns 2 and 4 into duplicates. That is why every provider above delivers at least once: if it doesn't see a 2xx in time, it sends again, even when your server did the work and only the response was lost. Exactly-once delivery over a network isn't possible; what you can build is exactly-once processing, by making the receiver ignore what it has already seen.

GuaranteeWhat it meansWho offers it
At most onceSent once, never retried; may be lostFire-and-forget senders, GitHub without manual redelivery
At least onceRetried until acknowledged; may be duplicatedStripe, Shopify, Slack, Meta and every webhook gateway
Exactly onceNever lost, never duplicatedNobody, over HTTP. You build it on top of at-least-once with idempotent processing

Two decisions make deduplication work:

  • Which id to key on. Use the id that stays the same across retries of the same event. That is the event id for Stripe (evt_…) and Standard Webhooks (webhook-id), X-GitHub-Delivery for GitHub, and X-Shopify-Webhook-Id for Shopify. Shopify's X-Shopify-Event-Id is shared by deliveries to different subscriptions, so keying on it would drop legitimate deliveries if you have two.
  • How long to remember it. At least the longest retry window you receive from, plus your own retry window. Stripe retries for 3 days, so keep Stripe ids for a week; a day covers Shopify's 4 hours. Then delete them on a schedule so the dedupe table doesn't grow forever, or keep ids in Redis with a TTL at high volume:
SQL · nightly cleanup (keep ids longer than every retry window)
delete from webhook_events
 where status = 'done' and received_at < now() - interval '7 days';
Redis · seen-id check with a 7-day TTL
# Returns OK the first time, nil if the id was already seen
SET webhook:<provider>:<event_id> 1 NX EX 604800
  • Also dedupe on the business key for one-time side effects. One business action can arrive as several different events with different ids: a Stripe payment can produce both payment_intent.succeeded and charge.succeeded. If both trigger "send the receipt", key that side effect on the payment intent or order id, not the event id.
  • Prefer natural idempotency where you can. If the event says "order 42 changed", fetch order 42's current state from the provider's API and write that, instead of applying the event as a delta. Duplicates and out-of-order deliveries then converge on the right answer without any bookkeeping.
  • Know when you can skip it. Cache invalidation, analytics counters you can reconcile, and handlers that only fetch current state tolerate duplicates as they are.

The five-stage delivery pipeline

Reliable webhook delivery pipeline: 1 receive (verify the signature, store the event, then return 200), 2 queue in durable storage that survives restarts, 3 process idempotently on the event id, 4 retry with backoff and jitter until a deadline, 5 dead-letter to inspect, fix and replay. Around it, monitoring tracks success rate, latency and retry backlog and alerts on failures and silence, and a reconciliation job asks the provider API for anything that never arrived.

1. Receive: verify, store, then acknowledge

The receiving endpoint has one job: get the event somewhere durable and answer before the provider's timeout. Verify the signature against the raw body, write the event with its id, and return 200. Nothing else happens in the request: no API calls, no emails, no business logic.

receiver.js · acknowledge after storing
app.post('/webhooks/:provider', express.raw({ type: '*/*' }), async (req, res) => {
  if (!verifySignature(req.params.provider, req)) return res.sendStatus(401)

  // Unique (provider, event_id): a duplicate delivery is a no-op insert.
  await db.query(
    `insert into webhook_events (provider, event_id, headers, body, status)
     values ($1, $2, $3, $4, 'pending')
     on conflict (provider, event_id) do nothing`,
    [req.params.provider, eventId(req), req.headers, req.body]
  )
  res.sendStatus(200)
})

If storing fails, return 500 so the provider retries. That is the one failure you want the provider to see.

2. Queue: durable storage between receive and process

The stored event is the queue. A database table with a status column is enough for most teams, because the insert and the acknowledgement already happen in the same place. At higher volume, publish to SQS, Pub/Sub or Kafka after the insert. What matters is that a crash, deploy or restart of the worker loses nothing: unprocessed events are still pending when it comes back.

3. Process: idempotent on the event id

Workers pick up pending events and do the real work. Because every stage is at-least-once, the same event will sometimes be processed twice. A handler is idempotent when the second run changes nothing:

  • Key on the id that stays the same across retries (see which id to key on), never on a hash of the payload. Standard Webhooks senders put it in webhook-id.
  • Put the side effect and the "processed" mark in one transaction, or make the side effect itself idempotent with an idempotency key on the outbound call.
  • Guard against out-of-order delivery: compare the object's updated_at with what you have and ignore older versions.

Webhook retries and idempotency goes deeper on duplicate handling.

4. Retry: backoff, jitter and a deadline

When processing fails, retry from storage, not from the provider. Use exponential backoff so a struggling dependency gets room to recover, add jitter so a thousand failed events don't retry in the same second, and set a deadline after which the event moves to the dead-letter stage.

An example schedule with a 30-second base, doubling each time, ±20% jitter and a deadline after the 12th retry:

RetryWait before itElapsed since first failureWhat it covers
130 s30 sNetwork blip, connection reset
21 min1.5 minPod restart
32 min3.5 minRolling deploy
44 min7.5 minSlow deploy, brief dependency outage
58 min15.5 minDependency outage that outlasts a deploy
616 min31.5 minIncident someone is already fixing
732 min~1 hRegional cloud incident
864 min~2 hHours-long outage
9~2 h~4 hHalf-day outage
10~4.3 h~8.5 hOvernight outage
11~8.5 h~17 hOutage longer than a working day
12~17 h~34 h, then dead-letterA day and a half, then a human looks

That is 12 retries over about 34 hours. Cap the wait (a day is common) if you extend the schedule further; otherwise the gaps grow past anything useful.

A worker that claims due events, retries them and dead-letters them at the deadline fits in one query and one loop:

worker.js · claim, process, retry or dead-letter
const MAX_RETRIES = 12

async function runOnce() {
  const db = await pool.connect()
  try {
    await db.query('begin') // the row locks below last until commit
    await processBatch(db)
    await db.query('commit')
  } catch (err) {
    await db.query('rollback')
    throw err
  } finally {
    db.release()
  }
}

async function processBatch(db) {
  // Claim due events; SKIP LOCKED lets several workers run safely.
  const { rows } = await db.query(
    `select * from webhook_events
      where status = 'pending' and next_attempt_at <= now()
      order by next_attempt_at limit 50
      for update skip locked`)

  for (const ev of rows) {
    try {
      await handle(ev) // idempotent: safe to run twice
      await db.query(`update webhook_events set status = 'done'
                      where id = $1`, [ev.id])
    } catch (err) {
      const attempts = ev.attempts + 1
      const permanent = err.permanent || attempts > MAX_RETRIES
      const wait = 30 * 2 ** (attempts - 1) * (0.8 + Math.random() * 0.4)
      await db.query(
        `update webhook_events
            set attempts = $2, last_error = $3,
                status = $4,
                next_attempt_at = now() + make_interval(secs => $5)
          where id = $1`,
        [ev.id, attempts, String(err), permanent ? 'dead' : 'pending', wait])
    }
  }
}

Add an index on (status, next_attempt_at). The trade-off of this version is that row locks are held while handle() runs. If handlers are slow, claim with a lease instead and process outside the transaction:

SQL · claim a batch with a 5-minute lease
update webhook_events
   set status = 'processing', locked_until = now() + interval '5 min'
 where id in (select id from webhook_events
               where status = 'pending' and next_attempt_at <= now()
               order by next_attempt_at limit 50
               for update skip locked)
returning *;

A periodic job puts rows whose lease expired back to pending, which covers a worker that died mid-batch. SKIP LOCKED is what stops two workers from claiming the same event at the same moment and processing it twice; the idempotent handler covers the remaining case, where a worker crashes after the side effect but before marking the event done.

Separate retryable failures from permanent ones. A 503 from a dependency is worth retrying; a payload that fails validation will fail identically every time and should go straight to the dead-letter stage.

Response or errorRetry?Why
Timeout, connection refused, DNS failureYesTransient by nature
5xxYesServer-side, usually temporary
429 Too Many RequestsYes, after Retry-AfterBack off as asked
408 Request TimeoutYesTransient
3xxNo; fix the URLShopify counts a redirect as a failure, and most senders do not follow one
400, 422No; dead-letterThe payload won't change between attempts
401, 403No; dead-letter and alertA credential or permission is broken
404, 410 GoneNo; alertThe endpoint moved or was removed on purpose

5. Dead-letter: park it, fix it, replay it

A dead-letter queue keeps events that exhausted their retries, with the payload and the last error, so they stop clogging the main queue without being thrown away. The operational loop is: an alert fires, someone reads the last error, ships a fix, and replays the dead-lettered events through the normal handler. Replay is safe because stage 3 is idempotent. Without a replay path, a dead-letter queue is just a slower way to lose data.

Around the pipeline: monitoring and reconciliation

The five stages handle events that arrive. Two more jobs handle the ones that don't, and the ones that fail quietly.

Monitoring. Track success rate, latency and retry backlog per source and destination, and alert on the first failed delivery or on an error-rate threshold. Add a silence alert: if a source that normally sends every few minutes has sent nothing for an hour, something upstream is broken, and no failure counter will tell you. Webhook monitoring covers which metrics to track and how to set the thresholds.

Reconciliation. On a schedule, ask the provider's API for objects that changed since the last run and process any you have not seen. Stripe's GET /v1/events lists events for 30 days. Shopify's Admin API can query orders and products by updated_at. GitHub's API lists recent deliveries per webhook, so you can redeliver failed ones. Reconciliation turns "the provider never sent it" from data loss into a delay.

See durable retries converge

The chart below is a real run: 50 webhooks sent through Webhook Relay to flakey-script, an open-source endpoint that rejects about 80% of requests until an event is an hour old, with durable retries on the medium schedule. Fast retries run for the first 15 minutes, then durable retry takes over; the endpoint starts accepting old events at 60 minutes, and the last webhook lands at 73 minutes. Press play to watch, or switch to Retry attempts to see the 275 attempts behind the curve.

Durable delivery · live convergence
50/ 50 delivered
100%at 73 min
025500m15m30m45m60m75m90mhandoff to durable retryendpoint recovers
DeliveredRetryingAttempts50 webhooks · 275 attempts · 100% by ~73 min

The result: 50 of 50 webhooks delivered, the last at 73 minutes, after 275 attempts. With only a provider's own retries, anything still failing after Shopify's 4-hour window, or immediately for GitHub, would have been dropped.

Build it or use a gateway

There are three ways to get the pipeline. They are not exclusive: most teams that use a gateway still keep idempotent handlers and a reconciliation job.

Build it yourselfProvider-native queuesManaged webhook gateway
What it isYour endpoint + a queue (SQS, Pub/Sub, a table) + workers + DLQProvider delivers straight into your cloud queue (Shopify → Pub/Sub or EventBridge, Stripe → EventBridge)A hosted endpoint that stores, retries, logs and forwards (Webhook Relay, Hookdeck)
Retry windowWhatever you buildYour queue's retentionConfigurable; up to 30 days on Webhook Relay
Works for every providerYesOnly providers that support itYes
Logs, alerting, replayYou build themYour cloud's toolingIncluded
Private-network receiversNeeds inbound access or a tunnelYes, via the queueWebhook Relay delivers through an outbound agent
Operational costHighestMediumLowest
Lock-inNonePer provider and cloudPer gateway; endpoints are plain HTTPS

Webhook Relay sits in the third column. A bucket gives each provider a stable HTTPS URL that answers in milliseconds and stores the request, then durable retries deliver it with exponential backoff for up to 30 days. Alerts open an incident on the first failed delivery, request logs keep every attempt, and one click resends a delivery or recovers every failure after a fix. The relay agent delivers into localhost, private networks and Kubernetes without inbound firewall rules, and throttling stops a backlog from flattening a recovering service.

If you send webhooks

Everything above is the receiving side. If your product sends webhooks to customers, the same rules apply in reverse: sign every message, retry with backoff for a published window, give each customer endpoint its own rate limit so one slow customer doesn't delay the rest, keep a delivery log your support team can search, and let customers replay. Webhook Relay's Outbound Gateway does that with Standard Webhooks signatures and 35 days of history on every plan; Svix and Hookdeck Outpost are the main alternatives.

Next steps

Reliability checklist

This list covers the pipeline. For what to watch and alert on, use the monitoring checklist.

  • The endpoint answers within the strictest provider timeout you receive from (3 s for Slack).
  • The raw event is stored before 200 is returned; storage failure returns 500.
  • Every event has a unique constraint on provider + event id.
  • Handlers ignore payloads older than the stored object.
  • Processing failures retry from storage with backoff, jitter and a deadline.
  • Permanent failures skip retries and go to the dead-letter queue.
  • Dead-lettered events can be replayed through the normal handler.
  • Alerts fire on failed deliveries and on silence from busy sources.
  • A reconciliation job runs on a schedule for every provider that has a list API.
  • Deploys never take the receiving endpoint down for longer than the shortest retry window, or a buffer sits in front of it.

Frequently asked questions

How do I make sure I never miss a webhook?

No single setting guarantees it. Combine four things: answer within the provider's timeout by storing the event and returning 200 before doing any work; retry your own processing failures from durable storage with backoff; alert on failed deliveries and on unusual silence; and run a scheduled reconciliation job that asks the provider's API for objects changed since the last run, so events that never arrived are still processed.

Which queue ensures reliable webhook delivery?

Any durable queue works for the processing side, for example Amazon SQS with a dead-letter queue, Google Cloud Pub/Sub, RabbitMQ with quorum queues, or a database table used as an outbox. The queue only protects events after your endpoint has accepted them. You still need an endpoint that is up and fast enough to accept every delivery, idempotent consumers, a dead-letter path with replay, and reconciliation for events the provider never delivered.

Are webhooks delivered exactly once?

No. Webhooks are delivered at least once. A provider retries when it does not see a 2xx in time, even if your server already processed the request, so the same event can arrive twice. Make handlers idempotent by recording each event id with a unique constraint and skipping ids you have already processed.

How long do webhook providers retry failed deliveries?

It varies a lot. Stripe retries for up to three days in live mode. Shopify retries eight times over four hours. Slack retries three times within about five minutes. The WhatsApp Cloud API retries for up to seven days. GitHub does not retry automatically at all, though you can redeliver from the last three days by hand or through the API.

What is a webhook dead-letter queue?

A dead-letter queue holds events that failed processing more times than your retry policy allows, so they stop blocking the main queue without being thrown away. Each entry keeps the original payload and the last error. After you fix the cause, you replay the dead-lettered events through the normal handler, which is safe because the handler is idempotent.

Should I build webhook retry infrastructure or use a service?

Build it when webhooks are central to your product, you already run queues and on-call, and you need full control of storage. Use a managed webhook gateway when you want durable retries, logs, alerting and replay without operating them, or when the receiver sits on a private network. Either way, keep idempotent handlers and reconciliation in your own code.