HookDeploy
Intermediate

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

ProviderRetry policyTimeout
StripeUp to 3 days, exponential backoff~20 seconds
GitHub3 attempts10 seconds
Shopify19 attempts over 48 hours5 seconds
ClerkMultiple retries over 24 hours~10 seconds
TwilioConfigurable, default retries15 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:

ProviderKey field
Stripeevent.id (evt_...)
GitHubX-GitHub-Delivery header
ShopifyX-Shopify-Webhook-Id header
Clerksvix-id header (Svix-based)

Handling duplicates

Duplicates arrive because:

  1. Provider retry — your handler returned non-2xx or timed out
  2. At-least-once delivery — most providers guarantee at-least-once, not exactly-once
  3. 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:

  • INSERT without 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:

  1. Sender never retries due to your handler — HookDeploy returns 200 on capture
  2. You control replay — retry to your handler when it is ready, not when the provider decides
  3. Inspect before processing — see the exact payload before writing code to handle it
  4. 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:

FailureWho retries
HookDeploy capture fails (429, 413)Sender retries
HookDeploy forward fails (your server down)Nobody — you replay manually
Your handler returns 500 via forwardSender 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

  • 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.