# Webhook retry handling for payment events

- **Pattern:** `ab-002258` (`saas-billing.subscription-mgmt.webhook-retry-handling`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002258
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002258)

## Why it matters

Stripe retries failed webhook deliveries up to 3 days after the first failure. If your webhook handler uses plain `db.create()` or `db.insert()` rather than upserts, a retried event will either throw a unique constraint violation (silently dropping the event) or insert a duplicate row (corrupting subscription state). CWE-345 (Insufficient Verification of Data Authenticity) and ISO 25010 functional-correctness both apply. Handlers that perform slow synchronous operations (email sends, third-party API calls) before returning 200 also cause Stripe to time out and trigger additional retries, compounding the problem.

## Severity rationale

Medium because non-idempotent handlers corrupt subscription state on retry and can leave billing records permanently inconsistent.

## Remediation

Use upsert operations for all database writes in webhook handlers, and return 200 before performing any slow operations. Queue emails and third-party calls for background processing.

```ts
case 'customer.subscription.updated': {
  const subscription = event.data.object

  // upsert is safe to call multiple times with the same event
  await db.user.upsert({
    where: { stripe_customer_id: subscription.customer as string },
    update: { subscription_status: subscription.status, plan: subscription.items.data[0]?.price.lookup_key ?? 'free' },
    create: { /* ... */ }
  })

  // queue slow operations — don't block the 200 response
  await queue.enqueue('subscription-changed-email', { customerId: subscription.customer })

  return new Response('OK', { status: 200 }) // respond quickly
}
```

Alternatively, store processed event IDs in a `processed_webhook_events` table and skip re-processing if the event ID already exists.

## Detection

- **ID:** `webhook-retry-handling`
- **Severity:** `medium`
- **What to look for:** Stripe (and other providers) will retry failed webhook deliveries multiple times. Check whether your webhook handler is idempotent — can it safely process the same event twice without corrupting state? Look for duplicate event protection mechanisms: checking whether an event ID has already been processed (storing processed event IDs), or using upsert operations instead of insert operations so re-processing has no harmful effect. Also check the webhook handler's response time — handlers that take too long will cause Stripe to time out and retry.
- **Pass criteria:** Count every database write operation in webhook handlers. Webhook handler uses upsert operations (not pure inserts) for 100% of database writes, OR stores processed event IDs to prevent duplicate processing. The handler returns a 200 response quickly (before lengthy processing completes), using background processing for slow operations if needed.
- **Fail criteria:** Webhook handler uses plain inserts that will fail or duplicate data on retry. Handler performs long-running synchronous operations that may time out before responding. No mechanism to handle duplicate event delivery.
- **Skip (N/A) when:** No webhook endpoint detected.
- **Detail on fail:** `"Webhook handler uses db.create() — duplicate event delivery will fail with unique constraint or create duplicate records"` or `"Webhook handler performs 30s email send synchronously before responding — Stripe may time out and retry"`
- **Remediation:** Make webhook handlers idempotent with upserts and fast responses:

  ```ts
  case 'customer.subscription.updated': {
    const subscription = event.data.object

    // Upsert is safe to call multiple times
    await db.user.upsert({
      where: { stripe_customer_id: subscription.customer as string },
      update: { subscription_status: subscription.status },
      create: { /* ... */ }
    })

    // Queue slow operations (emails, etc.) for background processing
    await queue.enqueue('subscription-updated-email', { customerId: subscription.customer })

    // Return 200 quickly
    return new Response('OK', { status: 200 })
  }
  ```

---

## External references

- cwe CWE-345
- iso-25010 functional-correctness

Taxons: data-integrity, error-resilience

HTML version: https://auditbuffet.com/patterns/ab-002258
