Blog ·
Designing Webhook Retries: Schedules, Jitter, Idempotency and Auto-Disabling Endpoints
How to design webhook retries: backoff with jitter, webhook-id deduplication, timeouts and auto-disabling, with Stripe, Shopify, GitHub and Svix policies.
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
}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) };
}
}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.
The receiver verifies the signature first. A Standard Webhooks signature is HMAC-SHA256 over ${webhook-id}.${webhook-timestamp}.${body}, keyed with the base64-decoded part of the secret after whsec_. Use the raw request bytes. If your framework parses the JSON and you stringify it again, whitespace and number formatting change and the signature won't match. This is the most common cause of "signature verification failed."
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET!.replace(/^whsec_/, ''), 'base64');
const TOLERANCE_SEC = 5 * 60;
export function verify(raw: Buffer, h: IncomingHttpHeaders): boolean {
const id = String(h['webhook-id'] ?? '');
const ts = String(h['webhook-timestamp'] ?? '');
const sigs = String(h['webhook-signature'] ?? '').split(' ');
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC) return false;
const expected = createHmac('sha256', SECRET).update(`${id}.${ts}.`).update(raw).digest();
// During key rotation several signatures arrive, space-separated. One match is enough.
return sigs.some((s) => {
const [ver, b64] = s.split(',');
if (ver !== 'v1' || !b64) return false;
const got = Buffer.from(b64, 'base64');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}Then record the ID in a table with a unique constraint. If the insert does nothing, it's a duplicate.
// Express receiver: take the raw body, verify, dedupe, enqueue, respond
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const id = req.header('webhook-id');
if (!id || !verify(req.body, req.headers)) return res.status(400).end();
const inserted = await db.query(
'INSERT INTO processed_webhooks (id) VALUES ($1) ON CONFLICT DO NOTHING',
[id],
);
if (inserted.rowCount === 0) return res.status(200).end(); // duplicate: still 2xx
await queue.add('handle-webhook', { id, body: req.body.toString('utf8') });
res.status(200).end(); // do the heavy work in the queue
});Two details matter here. Return 2xx for duplicates too; a 4xx makes the sender retry again. And return 2xx before doing slow work. Stripe's docs say the same: respond quickly, then run the complex logic. 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.
Ordering isn't guaranteed either. Stripe says so explicitly and suggests fetching the current object from the API instead of relying on event order. With retries in play, "created → deleted → updated" can arrive in that order. Put the object ID in the payload and let receivers re-fetch state.
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:
- 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.
- 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.
| 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 schedule is fixed, 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-signaturewithv1HMAC-SHA256).webhook-idstays 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}}'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