# Webhook retry logic is handled with idempotent processing design

- **Pattern:** `ab-001172` (`ecommerce-payment-security.payment-errors.webhook-idempotent-processing`)
- **Severity:** low
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001172
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001172)

## Why it matters

Stripe retries webhook delivery up to 3 days when it doesn't receive a 200 response within 30 seconds. If your webhook handler performs a plain `db.orders.insert()` keyed only on customer data — not on `event.id` or `paymentIntent.id` — every retry creates a duplicate order record, triggers a duplicate fulfillment, and sends the customer a duplicate confirmation email. CWE-362 (Race Condition) applies when two concurrent deliveries of the same event race to insert the same order. This is not a theoretical failure: network blips during high-traffic events (flash sales, holiday peaks) cause Stripe retries at exactly the moment your database is under maximum load.

## Severity rationale

Low because non-idempotent webhook handlers create duplicate orders and emails on every Stripe retry, with retry frequency increasing precisely when database load causes initial delivery to timeout.

## Remediation

Deduplicate by recording processed event IDs in a database table before executing business logic.

```ts
export async function POST(req: Request) {
  // ... signature verification ...

  await db.$transaction(async (tx) => {
    // Mark the event as processed first — unique constraint prevents double-processing
    await tx.processedWebhookEvents.create({ data: { stripeEventId: event.id } })

    switch (event.type) {
      case 'payment_intent.succeeded':
        // Upsert instead of insert for additional safety
        await tx.orders.upsert({
          where: { stripePaymentIntentId: event.data.object.id },
          create: { /* order fields */ },
          update: { status: 'confirmed' },
        })
        break
    }
  })

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

Add a unique index on `processed_webhook_events.stripe_event_id`. The unique constraint causes the second delivery's `create` to throw, which is caught by the `$transaction` and aborted cleanly.

## Detection

- **ID:** `webhook-idempotent-processing`
- **Severity:** `low`
- **What to look for:** Check whether webhook handlers deduplicate events before processing. List all state-changing operations in webhook handlers (inserts, updates, email sends) and for each, classify as idempotent (upsert, uniqueness constraint, dedup check) or non-idempotent (plain insert without constraint). Look for checks against a `processed_events` database table or cache keyed by `event.id` before performing state changes. Count the total write operations and the ratio that are idempotent.
- **Pass criteria:** Webhook handlers check whether the event ID has already been processed before executing business logic. 100% of database writes use upsert or uniqueness constraints keyed on the event ID or payment intent ID. Processing the same event twice produces the same outcome as processing it once.
- **Fail criteria:** Webhook handlers execute state-changing operations (insert order, decrement inventory, send email) without any deduplication check. The same event arriving twice (due to network retry) would create duplicate records or send duplicate emails.
- **Skip (N/A) when:** The project has no webhook handlers implemented.
- **Detail on fail:** Describe the non-idempotent pattern. Example: `"Webhook handler calls db.orders.insert() on payment_intent.succeeded without checking if an order with that paymentIntentId already exists — Stripe retries will create duplicate orders"`
- **Remediation:** Deduplicate events by tracking processed event IDs:

  ```ts
  export async function POST(req: Request) {
    // ... signature verification ...

    // Check if this event was already processed
    const existing = await db.processedWebhookEvents.findUnique({
      where: { stripeEventId: event.id }
    })

    if (existing) {
      return new Response('Already processed', { status: 200 })
    }

    // Mark as processed first (within a transaction) to prevent races
    await db.$transaction(async (tx) => {
      await tx.processedWebhookEvents.create({ data: { stripeEventId: event.id } })

      switch (event.type) {
        case 'payment_intent.succeeded':
          // Use upsert instead of insert for additional idempotency
          await tx.orders.upsert({
            where: { stripePaymentIntentId: event.data.object.id },
            create: { /* ... */ },
            update: { status: 'confirmed' }, // Safe to update if already exists
          })
          break
      }
    })

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

## External references

- cwe CWE-362
- iso-25010 reliability.fault-tolerance

Taxons: data-integrity, error-resilience

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