# Payment operations are idempotent

- **Pattern:** `ab-002274` (`saas-billing.financial-data.payment-idempotency`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002274
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002274)

## Why it matters

Network errors and client retries are normal in distributed systems. Without idempotency keys, a retry after a timeout can create two Stripe Payment Intents for the same purchase — charging the customer twice. CWE-362 (Race Condition) and ISO 25010 functional-correctness both apply. Stripe's idempotency key mechanism guarantees that if you send the same key twice, you get the same response without creating a second charge — but you must opt in explicitly on every payment initiation call.

## Severity rationale

Medium because duplicate charges are recoverable via refund but cause customer trust damage, support burden, and potential chargeback risk.

## Remediation

Pass an idempotency key on every Stripe payment initiation call. Tie the key to the specific purchase intent so retries within a session return the existing object rather than creating a new one.

```ts
// Stable idempotency key: user + price + minute-level timestamp
// Minute-level granularity: allows retries within the same intent window
// but a new attempt a minute later gets a fresh key (new purchase intent)
const idempotencyKey = `checkout-${userId}-${priceId}-${Math.floor(Date.now() / 60000)}`

const session = await stripe.checkout.sessions.create(
  {
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${baseUrl}/pricing`,
  },
  { idempotencyKey }
)
```

Alternatively, check for an existing incomplete checkout session in your database before creating a new one, and return the stored session URL on retry.

## Detection

- **ID:** `payment-idempotency`
- **Severity:** `medium`
- **What to look for:** Check API routes that initiate payments, create checkout sessions, or create subscriptions. Look for Stripe idempotency key usage (`idempotencyKey` option on Stripe API calls). Idempotency keys prevent duplicate charges when a network error causes a client to retry a request. Check whether checkout session creation or payment intent creation endpoints can be safely called multiple times without creating duplicate charges.
- **Pass criteria:** Count every payment initiation endpoint — at least 1 must have idempotency protection. Payment initiation endpoints use Stripe idempotency keys, OR use Stripe Checkout Sessions in a way that naturally prevents duplicates (creating a session returns the same session if called again with the same parameters). Checkout session IDs stored in the database prevent duplicate session creation for the same purchase intent.
- **Fail criteria:** Payment intent or charge creation endpoints can be called multiple times without idempotency protection, creating the risk of duplicate charges on network retry.
- **Skip (N/A) when:** No payment initiation code detected (application uses only webhooks to receive payment events, not to initiate payments).
- **Detail on fail:** `"POST /api/billing/checkout creates a new Stripe Payment Intent on every call without idempotency key — network retries can cause duplicate charges"` or `"No idempotency keys used on any Stripe API calls in payment flow"`
- **Remediation:** Use Stripe idempotency keys tied to the user's intent:

  ```ts
  // Generate a stable idempotency key tied to this specific purchase intent
  const idempotencyKey = `checkout-${userId}-${priceId}-${Date.now().toString().slice(0, -3)}`
  // (truncate to minute-level to allow retries within the same minute but prevent
  //  accidental reuse across different purchase intents)

  const session = await stripe.checkout.sessions.create(
    { customer: customerId, line_items: [...], mode: 'subscription' },
    { idempotencyKey }
  )
  ```

  Alternatively, check for an existing incomplete checkout session before creating a new one, and return the existing session URL if found.

## External references

- iso-25010 functional-correctness
- cwe CWE-362

Taxons: data-integrity, error-resilience

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