PAYMENTSTEMPLATE

Stripe Webhook Handler

Every Stripe integration is secretly a webhook integration. Checkout, subscriptions, refunds, disputes: the browser sees a redirect, but the facts arrive later, server to server, as events. If your handler is sloppy, every feature built on top of it inherits the sloppiness, and billing bugs are the expensive kind. Customers notice them, and they notice in the direction that costs you either money or trust.

Three properties make a webhook handler trustworthy, and none of them are optional. It verifies signatures against the raw request body, because an unverified webhook endpoint is a public API that edits your billing table; anyone with curl can call it. It is idempotent, because Stripe delivers at-least-once and will happily send you the same event twice; a handler that isn't idempotent sends two receipts, grants two credits, or double-fulfills an order. And it treats the event as a doorbell, not a database: events can arrive out of order, so on anything that matters you fetch the current object from the Stripe API and write that, instead of trusting a payload that may already be stale.

There is a fourth rule that isn't about the handler at all: UI state never substitutes for webhook truth. The success redirect is a hint. The customer's card can be declined on retry, the subscription can lapse, the dispute can land, all without a single click in your app. The webhook is where the database learns the truth, which means it is the only code allowed to write it.

This spec builds the handler as shared infrastructure: verify once, dedupe once, then dispatch to small per-event functions. Hand it to your agent before you build the features that depend on it.

Prerequisites

  • A Stripe account and a working `STRIPE_SECRET_KEY` in your server env.
  • The Stripe CLI installed (`stripe listen` for local delivery, `stripe events resend` for replay testing).
markdown
# Spec: Stripe webhook handler

Build POST /api/webhooks/stripe as the single entry point for all Stripe
events, and the only code in this codebase that writes billing state.

## Stack

- Framework: [e.g., Next.js 14 App Router / Express / FastAPI]
- Database: [e.g., Postgres / Supabase / SQLite]

## Environment variables

```bash
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...  # `stripe listen` prints one locally;
                                 # production gets its own from the dashboard
```

## Rule 1: verify the signature against the RAW body

- Disable framework body parsing for this route ([Next.js: read the raw
  request body / Express: `express.raw({type: 'application/json'})`]).
  Signature verification hashes the exact bytes; a parsed-then-reserialized
  body fails verification even for legitimate events.
- `stripe.webhooks.constructEvent(rawBody, signatureHeader, secret)`.
  On failure: log it, return 400, write nothing.

## Rule 2: idempotency via a processed-events table

```sql
create table stripe_events (
  id text primary key,            -- Stripe event id (evt_...)
  type text not null,
  processed_at timestamptz not null default now()
);
```

- Insert the event id BEFORE doing work. On primary-key conflict, return 200
  immediately: we have seen this event, the retry is a no-op.
- Every side effect (emails, credits, fulfillment) lives behind this gate.

## Rule 3: explicit event allowlist, 200 for everything else

Handle only the events this app has a handler for. Start with:

- `checkout.session.completed`   -> [fulfill the order / start the subscription]
- `customer.subscription.updated`-> [sync status, price, period end]
- `customer.subscription.deleted`-> [revoke access]
- `invoice.payment_failed`       -> [mark past_due, notify the customer]
- `charge.refunded`              -> [mark the order refunded, revoke if digital]
- `charge.dispute.created`       -> [flag the account, alert a human — no
                                     automated response to disputes]

Unknown types get a 200 and a debug log line. Never a 500: Stripe retries
errors, and a handler that 500s on events it doesn't care about DDoSes
itself with its own backlog.

## Rule 4: the event is a doorbell, not the truth

For subscription and payment state, use the event only to learn WHICH object
changed, then retrieve that object fresh from the API and write from the
response. Events arrive out of order; the API answer is current.

## Structure

```typescript
export async function POST(req: Request) {
  const event = verifyOrThrow(await req.text(), req.headers); // 400 on throw
  if (!(await recordEventOnce(event.id, event.type))) return ok(); // dup
  const handler = handlers[event.type];                // the allowlist
  if (handler) await handler(event);                   // throw -> 500 -> retry
  return ok();                                         // 200 within ~10s
}

const handlers: Record<string, (e: Stripe.Event) => Promise<void>> = {
  "checkout.session.completed": handleCheckoutCompleted,
  "customer.subscription.updated": handleSubscriptionSynced,
  // one small function per event; no billing writes anywhere else
};
```

- Return 200 fast. If a handler needs slow work ([PDF generation / a
  third-party call]), record the event and queue the work; don't make Stripe
  wait on it.
- Throw on real failures (database down) so Stripe retries. Swallowing errors
  and returning 200 deletes the retry and the event is gone for good.

## Verification (before calling this done)

1. `stripe listen --forward-to localhost:3000/api/webhooks/stripe`, then
   `stripe trigger checkout.session.completed`: handler fires, row lands in
   `stripe_events`, the side effect happens once.
2. Resend the same event with `stripe events resend`: 200, zero new side
   effects.
3. POST a hand-forged body with curl and no valid signature: 400, nothing
   written.
4. Trigger an event type not in the allowlist: 200, log line, no writes.

Adaptation notes:

  • This handler is the foundation the subscription and one-time checkout templates plug into: their event handlers drop into the handlers map, and rules 1 through 4 come for free.
  • Same shape works for every webhook sender: GitHub, Twilio, Resend, Supabase. Signature scheme and event names change; verify-dedupe-dispatch does not.
  • In production, register the endpoint in the Stripe dashboard and subscribe it to only your allowlisted events. Less noise, and the webhook secret there is a different value than your local one; mixing them up is the classic "works locally, 400s in prod."
  • Prune stripe_events rows older than [90 days] on a schedule. The dedupe window only needs to outlive Stripe's retry window, which is days, not years.
  • The mistake: skipping the dedupe table because "I'll just write the handler carefully." Idempotency is a property of the data model, not of your intentions. The table is fifteen lines; the double-refund incident report is longer.