DEV Community

Cover image for Designing Webhook Retries: Schedules, Jitter, Idempotency and Auto-Disabling Endpoints
mmiura for SHANNON LIMITED LIABILITY COMPANY

Posted on Originally published at webhookadmin.com

Designing Webhook Retries: Schedules, Jitter, Idempotency and Auto-Disabling Endpoints

Retrying webhooks comes down to four decisions: when to try again, when to give up, how the receiver tells a retry from a new event, and when to stop sending to an endpoint that's gone. We go through each one with the policies Stripe, Shopify, GitHub and Svix publish.

Retrying webhooks comes down to four decisions: when to try again, when to give up, how the receiver tells a retry from a new event, and when to stop sending to an endpoint that is clearly gone. This post walks through each one with the policies real providers publish, the code you need if you build it yourself, and how Webhook Admin handles it.

How popular providers retry

All of these were checked against the official docs on 2026-09-27.

Provider Retries Response timeout Signature header
Stripe Exponential backoff for up to 3 days (live mode); 3 times over a few hours in sandboxes Not stated Stripe-Signature (HMAC-SHA256)
Shopify Up to 8 times over 4 hours, then the subscription is removed 5 seconds X-Shopify-Hmac-SHA256
GitHub No automatic retries; redeliver manually or via the API 10 seconds X-Hub-Signature-256 (HMAC-SHA256)
Svix Immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h 15 seconds webhook-signature (Standard Webhooks)

Sources: Stripe, Shopify retries, Shopify signatures, GitHub failed deliveries, GitHub signatures, Svix

The retry window ranges from zero (GitHub) to about 4 hours (Shopify) to about 28 hours (Svix) to 3 days (Stripe). Four hours covers a bad deploy on the receiver's side but not a weekend outage. Three days covers the weekend, at the cost of a burst of stale events when the receiver comes back. Pick the window first, then fit the intervals into it.

If you build this yourself, you will probably start with your job queue's built-in retry: SQS visibility timeouts, Cloud Tasks, Sidekiq, BullMQ. That handles "the worker crashed, run the job again." It does not track failures per endpoint, stop sending to dead endpoints, or show your support team what happened to a delivery. Those parts are on you.

Backoff and when to give up

Exponential backoff

Start with short intervals and lengthen them as failures continue. Most transient failures (a dropped connection, a pod restart) clear within seconds. An endpoint that has been down for an hour will not recover faster because you hit it every minute.

const BASE_MS = 5_000;          // first retry delay
const CAP_MS = 10 * 3600_000;   // cap a single delay at 10 hours
const MAX_ATTEMPTS = 8;         // including the first attempt

/** Delay after `attempts` failures, or null to give up */
export function backoffMs(attempts: number): number | null {
  if (attempts >= MAX_ATTEMPTS) return null;
  const exp = Math.min(CAP_MS, BASE_MS * 2 ** (attempts - 1));
  return Math.floor(Math.random() * exp); // full jitter
}
Enter fullscreen mode Exit fullscreen mode

Jitter

The Math.random() is the jitter. Without it, the 1,000 deliveries that failed during a receiver's outage are all retried at the same moment, right as the receiver comes back up. Picking a random delay between zero and the backoff value spreads them out. This is the "Full Jitter" strategy from the AWS Architecture Blog post Exponential Backoff And Jitter (2015): random(0, min(cap, base * 2 ** attempt)).

Limiting how many requests you send to each endpoint at once solves the same thundering-herd problem from a different angle. More on that below.

After the last attempt

Keep the failed event and mark it as failed, so it can be replayed by hand. Stripe lets you resend from the Dashboard and the CLI. A "replay everything that failed since 14:00" action saves a lot of back-and-forth with customers after an incident on their side.

Timeouts and what counts as success

  • Timeout: 5 to 15 seconds is typical (Shopify 5, GitHub 10, Svix 15). A long timeout lets one slow endpoint tie up your workers.
  • Success: only 2xx. Treat 3xx as a failure and don't follow redirects; following them means sending to a URL you never validated.
  • Response body: store the first 1 KB or so. It is enough to see the receiver's error message without bloating your logs.
export async function sendOnce(url: string, headers: Record<string, string>, body: string) {
  const started = Date.now();
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers,
      body,
      redirect: 'manual',
      signal: AbortSignal.timeout(15_000),
    });
    const head = (await res.text()).slice(0, 1024);
    return { ok: res.status >= 200 && res.status < 300, status: res.status, head, ms: Date.now() - started };
  } catch (e) {
    return { ok: false, status: null, head: null, ms: Date.now() - started, error: String(e) };
  }
}
Enter fullscreen mode Exit fullscreen mode

Idempotency and webhook-id

Retries mean the receiver will sometimes get the same event twice. If the receiver finishes its work but the response is lost on the way back, the sender sees a failure and retries. Delivery is at-least-once, and deduplication happens on the receiving side.

To make that possible, give every event a unique ID and send the same ID on every retry. Standard Webhooks puts it in the webhook-id header. The timestamp (webhook-timestamp) and the signature are regenerated for each attempt; the ID never changes.

Your receiver docs should then tell customers three things: store the ID under a unique constraint and skip events already seen, return 2xx for duplicates too (a 4xx makes the sender retry again), and return 2xx before doing slow work. A handler that calls three external APIs before responding will hit the timeout, get marked as failed, and receive a retry for work it already did. Handling duplicate webhooks has receiver code for this.

Ordering isn't guaranteed either. With retries in play, "created → deleted → updated" can arrive in that order, so put the object ID in the payload and let receivers re-fetch state (Out-of-order webhooks).

Stopping endpoints that keep failing

A churned customer's URL or an expired domain will never succeed, and every retry to it holds a worker until the timeout. Handle this in two stages:

  1. Pause briefly (circuit breaker): after several consecutive failures to the same endpoint, stop sending for a few seconds, then let one request through to probe. This keeps one slow endpoint from delaying deliveries to everyone else.
  2. Disable: after failures have continued for days, disable the endpoint and notify someone. Svix disables after 5 days.

Make the disable visible to both the team that sends and the customer who owns the endpoint. Otherwise you find out when the customer asks why they stopped getting events three weeks ago.

How Webhook Admin handles retries

These are the values in Webhook Admin's code as of 2026-09-27. The table below is the default retry schedule; you can choose the number of retries and the interval per endpoint, as long as the last retry comes within 3 days of the first send.

Attempt Delay since previous attempt Time since first attempt
1 immediate 0
2 5 seconds 5 s
3 5 minutes ~5 min
4 30 minutes ~35 min
5 2 hours ~2 h 35 min
6 5 hours ~7 h 35 min
7 10 hours ~17 h 35 min
8 10 hours ~27 h 35 min
  • The default schedule is the same one Svix uses, with no jitter. Instead, each endpoint gets at most 10 concurrent requests by default, and after 5 consecutive failures Webhook Admin stops sending to it for 30 seconds, then lets one request through. Deliveries waiting for a slot stay in the queue.
  • Responses time out after 15 seconds. Only 2xx counts as success. Redirects aren't followed, and 410 is retried like any other failure. The first 1,024 bytes of each response are logged.
  • Signatures follow Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature with v1 HMAC-SHA256). webhook-id stays the same across retries; timestamp and signature are regenerated per attempt. After a secret rotation, both old and new signatures are sent for 24 hours.
  • An endpoint that has been failing for 5 days is disabled automatically. Alerts go to Slack, Teams, Chatwork, email or a webhook when failures have lasted an hour, when retries run out and when an endpoint is disabled, at most once an hour per endpoint.
  • Failed deliveries can be replayed from the dashboard or the API.

When you send through the API, add an Idempotency-Key so a retry on your side doesn't create a second message. The same key returns the same message ID for 24 hours.

curl -X POST https://api.webhookadmin.com/v1/messages \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-2026-0927-001" \
  -d '{"consumer":"customer_123","event_type":"invoice.paid","payload":{"invoice_id":"inv_001","amount":12000}}'
Enter fullscreen mode Exit fullscreen mode

Checklist

  • Retry window and intervals (exponential backoff with jitter, or per-endpoint concurrency limits)
  • Success rule (2xx only, no redirects) and timeout
  • An event ID that survives retries, plus receiver docs that say "dedupe on the ID" and "return 2xx first"
  • Storage for failed events and a way to replay them
  • Circuit breaking, auto-disabling and alerts for endpoints that keep failing

If you'd rather not build and maintain retries, delivery logs, auto-disabling and failure alerts yourself, Webhook Admin is free up to 50,000 messages a month: https://app.webhookadmin.com/signup

Quick FAQ

Should webhook retries use jitter?

With plain exponential backoff, yes. Without it, every delivery that failed during a receiver's outage is retried at the same moment. A per-endpoint concurrency limit solves the same problem from the sender side.

Which responses should count as delivered?

Only 2xx. Treat 3xx as a failure and don't follow redirects, because following them sends the payload to a URL you never validated.

What should happen after the last retry?

Keep the event marked as failed so someone can replay it, and disable endpoints that have failed for days, notifying both your team and the endpoint's owner.


Thanks for reading! What retry schedule do you use for outgoing webhooks, and how did you pick it? We'd love to hear in the comments.

This post first appeared on the Webhook Admin blog.

Top comments (0)