Webhook Retry Strategies — Stripe, GitHub, and Idempotency
How major providers retry failed webhooks, idempotency keys, handling duplicates, and why capture-before-process helps.
HookDeploy Team · May 22, 2026
Webhook providers retry when your endpoint returns a non-2xx status or times out. Understanding retry behavior prevents duplicate charges, double emails, and corrupted state.
How major providers retry
| Provider | Retry policy | Timeout |
|---|---|---|
| Stripe | Up to 3 days, exponential backoff | ~20 seconds |
| GitHub | 3 attempts | 10 seconds |
| Shopify | 19 attempts over 48 hours | 5 seconds |
| Clerk | Multiple retries over 24 hours | ~10 seconds |
| Twilio | Configurable, default retries | 15 seconds |
Exact schedules change — check your provider’s current docs. The pattern is consistent: retries happen automatically, and the same event may arrive multiple times.
Stripe retry schedule
Stripe retries for up to 3 days with increasing intervals:
- Initial delivery
- Retries at increasing intervals (minutes to hours)
- Marks endpoint as disabled after prolonged failure
Stripe Dashboard shows delivery attempts per event. A single payment_intent.succeeded may show 3+ delivery rows if your handler returned 500.
What Stripe expects: HTTP 200 within ~20 seconds. Return 200 immediately, process async.
GitHub retry schedule
GitHub makes up to 3 delivery attempts with short intervals. Failed deliveries appear in Recent Deliveries with red status icons.
Each attempt sends the same X-GitHub-Delivery ID — use it for idempotency.
Shopify retry schedule
Shopify is aggressive: up to 19 retries over 48 hours. After repeated failures, Shopify may remove the webhook subscription.
Shopify expects a response within 5 seconds — one of the shortest timeouts among major providers.
Idempotency keys
An idempotency key uniquely identifies a webhook event. Process each key at most once:
async function handleWebhook(event) {
const idempotencyKey = event.id; // Stripe: evt_xxx
const existing = await db.query(
'SELECT 1 FROM processed_events WHERE event_id = $1',
[idempotencyKey]
);
if (existing.rows.length > 0) {
console.log('Duplicate event, skipping:', idempotencyKey);
return; // still return 200 to stop retries
}
await db.query('BEGIN');
try {
await processEvent(event);
await db.query(
'INSERT INTO processed_events (event_id) VALUES ($1)',
[idempotencyKey]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
Provider-specific idempotency keys:
| Provider | Key field |
|---|---|
| Stripe | event.id (evt_...) |
| GitHub | X-GitHub-Delivery header |
| Shopify | X-Shopify-Webhook-Id header |
| Clerk | svix-id header (Svix-based) |
Handling duplicates
Duplicates arrive because:
- Provider retry — your handler returned non-2xx or timed out
- At-least-once delivery — most providers guarantee at-least-once, not exactly-once
- Manual replay — you or a teammate replayed from HookDeploy
All three are normal. Your handler must be idempotent.
Safe patterns:
- Upsert instead of insert (
ON CONFLICT DO NOTHING) - Check idempotency key before side effects
- Use database transactions around check + process
Unsafe patterns:
INSERTwithout uniqueness constraint on event ID- Incrementing counters without dedup
- Sending customer emails before idempotency check
Return 200 fast, process slow
The most common cause of unnecessary retries: slow handlers.
// Pattern: acknowledge immediately, queue for processing
app.post('/webhooks/stripe', async (req, res) => {
const event = verifyAndParse(req);
await queue.add('stripe-event', event); // Redis, SQS, Bull, etc.
res.status(200).json({ received: true });
});
// Worker processes asynchronously
worker.process('stripe-event', async (event) => {
await handlePayment(event);
});
Providers stop retrying once they get 200. Your queue handles retries on the processing side with your own logic.
Why capture-before-process helps
HookDeploy’s capture-first model decouples receipt from processing:
Sender → HookDeploy (200 OK, stored) → forward/replay → your handler
Benefits for retry management:
- Sender never retries due to your handler — HookDeploy returns 200 on capture
- You control replay — retry to your handler when it is ready, not when the provider decides
- Inspect before processing — see the exact payload before writing code to handle it
- History for debugging retries — compare multiple delivery attempts of the same event
If your forward target returns 500, the sender does not retry — only the forward fails. You replay manually from HookDeploy when your handler is fixed.
HookDeploy forward failures vs provider retries
These are independent:
| Failure | Who retries |
|---|---|
| HookDeploy capture fails (429, 413) | Sender retries |
| HookDeploy forward fails (your server down) | Nobody — you replay manually |
| Your handler returns 500 via forward | Sender does NOT retry (already got 200 from HookDeploy) |
This is why capture-before-process is powerful for development: provider retry storms stop.
Testing retry behavior
Simulate a failure to see provider retries:
// Temporarily return 500 for all webhooks
app.post('/webhooks/stripe', (req, res) => {
res.status(500).send('Simulated failure');
});
Trigger an event. Watch the provider’s delivery log for retry attempts. Then fix the handler, return 200, and confirm retries stop.
Use HookDeploy to capture the same event once and replay multiple times to test your idempotency logic without triggering provider retries.
Related guides
Related guides
- Webhook debugging guide
Systematic approach to debugging webhook issues — delivery logs, signatures, payloads, curl testing, and common HTTP error codes.
- Webhook security best practices
Signature verification for Stripe, GitHub, and Shopify. HMAC-SHA256, replay attacks, IP allowlisting, HTTPS, and secret rotation.
- Testing Stripe webhooks with HookDeploy
Complete guide to capturing, inspecting, and debugging Stripe webhook events using HookDeploy, the Stripe CLI, and signature verification.