STRIPE STARTER · WEBHOOK HANDLER
Webhook handler
/api/webhooks/stripe — the only API route in the project. Verifies signatures, filters by payment mode, dispatches to the per-event handler. Add a new event in two lines.
Stripe POSTs raw bytes to this route. We need the unmodified body to verify the HMAC signature, which is why this is an App Router POST handler reading request.text() directly — no JSON middleware in the chain. Verify first, parse later.
The order matters
- Validate the stripe-signature header is present and non-empty (Zod).
- Read the raw body with request.text().
- constructWebhookEvent(body, signature) — throws on tamper, returns a typed Stripe.Event on success.
- isEventRelevant(event.type) — skip events your mode doesn't care about.
- routeEvent — switch on event.type, call the matching handler.
- Return 200 on success, 400 on signature failure, 500 on handler throw (Stripe retries).
Adding a new event
Three steps: register the event in your Stripe webhook endpoint, add a case to routeEvent, drop a handler file in src/lib/stripe-events/. The mode filter (isEventRelevant) needs to include the new event too — add it to WEBHOOK_EVENTS_BY_MODE in src/lib/payments.ts.
// 1. src/lib/payments.ts — let the filter pass it through
subscription: new Set([
"checkout.session.completed",
"invoice.paid",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.trial_will_end", // ← new
]),
// 2. src/lib/stripe-events/on-trial-will-end.ts
export async function onTrialWillEnd(sub: Stripe.Subscription) {
// send a "your trial ends in 3 days" email
}
// 3. src/app/api/webhooks/stripe/route.ts — add the case
case "customer.subscription.trial_will_end":
await onTrialWillEnd(obj as Stripe.Subscription);
break;
// 4. src/lib/stripe-events/index.ts — re-exportWhy the `as` cast in routeEvent is okay
Stripe's TS types define event.data.object as an empty interface Object {} — they don't narrow based on event.type. So we cast at the dispatch point. The case label "checkout.session.completed" is verified by Stripe's HMAC signature; mismatched event types can't physically reach the cast.
Never JSON.parse the body before verifying
Re-serializing changes whitespace/key order. The signature was computed over the EXACT bytes Stripe sent. Parse a JSON object, stringify it back, recompute the HMAC — you'll get a different value and verification fails. Verify on raw text, then parse via the SDK's constructEvent.
Don't make the handler async-respond
Stripe's POST is synchronous. If your handler kicks off a background job and returns 200, that's fine. If it tries to fetch from itself, write to a queue, and wait — you'll hit Stripe's 30-second timeout. They retry on timeout. Then you have two events. Then idempotency matters more.
Returning 500 makes Stripe retry — on purpose
If your handler throws (DB down, third party flaky), the route returns 500. Stripe retries with exponential backoff for up to 3 days. This is desirable. Don't catch-and-200 unless you know you've successfully persisted intent to retry yourself.