The single most expensive billing bug an agent will hand you is a one-line optimization: it provisions the subscription in the checkout success handler, because that is where the user lands after paying and it is obviously the moment they became a customer. It works in every test you run, because you always complete the flow. Then a real customer pays, closes the tab during the redirect, and your database never learns they are a customer, so they are billed monthly by Stripe while your app treats them as a free trial that expired. The redirect is a UX event on the user's flaky browser. The payment is a fact, and Stripe tells you the fact through a webhook. This lesson builds billing where the webhook is the only source of truth.
Two paths, one truth
Stripe's subscription integration has a client-facing path and a truth path, and keeping them separate is the whole discipline.
The client-facing path is Checkout and the portal: you create a Checkout Session server-side and redirect the browser to Stripe's hosted page; after payment Stripe returns the user to your success_url. For managing an existing subscription (update card, cancel, view invoices) you create a billing portal session and redirect there. Both are redirects to Stripe-hosted UI, so you build almost nothing and store almost nothing from them. Critically, the success_url landing is not where you provision. It is where you say "thanks, we are setting up your account", nothing more.
The truth path is the webhook. Stripe sends signed events to your endpoint for everything that matters: checkout.session.completed when a subscription is created, customer.subscription.updated and customer.subscription.deleted as it changes and ends, invoice.paid as it renews, invoice.payment_failed when a charge fails. Your subscriptions table (4.5's schema) is written only from these events. The app reads that table to decide access; it never asks Stripe in the request path, and it never infers billing state from a redirect.
Creating Checkout and portal sessions
The checkout route creates a subscription-mode session for the org's price and stashes the org id so the webhook can tie the resulting customer back to a tenant. Note the amounts and price live in Stripe, not your code.
import Stripe from 'stripe'
import { requireOrg, assertStaff } from '@/lib/auth'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { orgId } = await req.json()
const ctx = await requireOrg(orgId)
assertStaff(ctx) // only owner/admin can buy
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
success_url: `${process.env.APP_URL}/settings/billing?ok=1`,
cancel_url: `${process.env.APP_URL}/settings/billing`,
// The bridge from Stripe's world back to our tenant. The webhook
// reads this off the completed session.
client_reference_id: orgId,
subscription_data: { metadata: { org_id: orgId } },
})
return Response.json({ url: session.url })
}The portal route needs the org's Stripe customer id, which we only have because a prior webhook stored it (a nice forcing function: you cannot open the portal for an org that never checked out):
import Stripe from 'stripe'
import { requireOrg, assertStaff } from '@/lib/auth'
import { createServiceClient } from '@/lib/supabase/service'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { orgId } = await req.json()
const ctx = await requireOrg(orgId)
assertStaff(ctx)
const db = createServiceClient()
const { data: sub } = await db
.from('subscriptions')
.select('stripe_customer_id')
.eq('org_id', orgId)
.single()
if (!sub?.stripe_customer_id) {
return new Response('No billing account yet', { status: 409 })
}
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id,
return_url: `${process.env.APP_URL}/settings/billing`,
})
return Response.json({ url: session.url })
}The webhook: verify, record, act
Three things happen in the handler, in order, and each is a defense against a specific failure. Verify the signature (reject forgeries). Record the event id before acting (idempotency, because Stripe redelivers). Then update the subscription row. The App Router detail that trips people: signature verification needs the exact raw body, so you read await req.text() and never req.json(), and you use the async verifier.
import Stripe from 'stripe'
import { createServiceClient } from '@/lib/supabase/service'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: Request) {
// 1. VERIFY. Raw body is mandatory; any reparse breaks the sig.
const body = await req.text()
const sig = req.headers.get('stripe-signature')
let event: Stripe.Event
try {
event = await stripe.webhooks.constructEventAsync(
body, sig!, webhookSecret,
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
const db = createServiceClient() // service role: webhooks are trusted
// 2. IDEMPOTENCY. Insert the event id; a duplicate collides on the
// primary key and we ack without re-processing.
const { error: dupe } = await db
.from('webhook_events')
.insert({ id: event.id, type: event.type })
if (dupe) {
return new Response('Already processed', { status: 200 })
}
// 3. ACT. Return 2xx fast; keep the work small and idempotent.
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session
const orgId = s.client_reference_id!
await db.from('subscriptions').upsert({
org_id: orgId,
stripe_customer_id: s.customer as string,
stripe_subscription_id: s.subscription as string,
status: 'active',
price_id: process.env.STRIPE_PRICE_ID!,
updated_at: new Date().toISOString(),
})
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
const orgId = sub.metadata.org_id
await db.from('subscriptions').update({
status: sub.status, // trialing|active|past_due|canceled|...
current_period_end:
new Date(sub.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
}).eq('org_id', orgId)
break
}
case 'invoice.payment_failed': {
// Mark past_due; the customer.subscription.updated event that
// follows will carry the authoritative status too.
const inv = event.data.object as Stripe.Invoice
await db.from('subscriptions')
.update({ status: 'past_due',
updated_at: new Date().toISOString() })
.eq('stripe_subscription_id', inv.subscription as string)
break
}
// invoice.paid: renewal; the subscription.updated event carries
// the new period end, so no separate handling is required here.
}
await db.from('webhook_events')
.update({ processed_at: new Date().toISOString() })
.eq('id', event.id)
return new Response('ok', { status: 200 })
}Two facts from Stripe's docs shape this handler and are easy to get wrong from memory. First, event ordering is not guaranteed: customer.subscription.created, invoice.created, and invoice.paid can arrive in any order, so each handler must be safe regardless of what preceded it, which is why the subscription row is an upsert and status handlers key off the authoritative field in their own payload rather than assuming a prior event landed. Second, duplicates happen: Stripe redelivers events (on your non-2xx, on network hiccups), so the event-id insert is the idempotency guard, and the docs recommend exactly this, logging processed event ids and skipping ones you have seen.
Test-mode discipline and the lifecycle test
Everything above is built and exercised in Stripe test mode. Locally, the Stripe CLI bridges Stripe's events to your machine: stripe listen --forward-to localhost:3000/api/stripe/webhook prints the whsec_... signing secret to put in .env.local, and stripe trigger <event> fires a specific event so you can drive the state machine without clicking through Checkout each time.
The verification gate for this dispatch is a scripted webhook lifecycle test: not "did checkout work once" but a sequence that walks the subscription through its states and asserts the subscriptions row after each, including the failure and the replay. Expressed as intent:
import { test, expect } from '@playwright/test'
import { sendWebhook, dbRow } from './helpers' // sign + POST a
// fixture event to the local endpoint; read the subscriptions row
const ORG = 'org_test_lifecycle'
test('subscription lifecycle: create, renew, fail, cancel, replay', async () => {
// CREATE: checkout.session.completed provisions the org.
await sendWebhook('checkout.session.completed', {
client_reference_id: ORG,
customer: 'cus_test', subscription: 'sub_test',
})
expect((await dbRow(ORG)).status).toBe('active')
// FAIL: a failed renewal moves it to past_due.
await sendWebhook('invoice.payment_failed', {
subscription: 'sub_test',
})
expect((await dbRow(ORG)).status).toBe('past_due')
// RECOVER: subscription.updated back to active.
await sendWebhook('customer.subscription.updated', {
metadata: { org_id: ORG }, status: 'active',
})
expect((await dbRow(ORG)).status).toBe('active')
// CANCEL: subscription.deleted ends it.
await sendWebhook('customer.subscription.deleted', {
metadata: { org_id: ORG }, status: 'canceled',
})
expect((await dbRow(ORG)).status).toBe('canceled')
})
test('replay of a processed event is a no-op (idempotency)', async () => {
const evt = { id: 'evt_dupe_1', client_reference_id: ORG,
customer: 'cus_test', subscription: 'sub_test' }
await sendWebhook('checkout.session.completed', evt)
const first = await dbRow(ORG)
// Same event id again: must not double-process or corrupt state.
const res = await sendWebhook('checkout.session.completed', evt)
expect(res.status).toBe(200)
expect((await dbRow(ORG)).updated_at).toBe(first.updated_at)
})
test('a forged event (bad signature) is rejected', async () => {
const res = await sendWebhook('checkout.session.completed',
{ client_reference_id: ORG }, { badSignature: true })
expect(res.status).toBe(400)
})Three properties make this a real gate: it drives the state machine through failure and recovery (not just the happy create), it asserts idempotency by replaying an event id, and it proves the signature check by sending a forgery. A billing test that only confirms a successful checkout tests the one path that was never in danger.
The dispatch
DISPATCH: briefcase Stripe subscriptions (test mode)
GOAL: staff can start a $49/mo subscription via Checkout and manage it via the portal; the signed webhook is the sole writer of the subscriptions table; a scripted lifecycle test proves create, renew, fail, cancel, idempotent replay, and forged-event rejection.
IN SCOPE: app/api/stripe/ checkout, portal, and webhook route.ts; lib/supabase/service.ts (service-role client, server-only); app/settings/billing UI (buttons that POST to checkout/portal); tests/billing/lifecycle.test.ts + helpers; .env.example (STRIPE_* placeholders); STATUS.md.
OUT OF SCOPE: multiple plans / seats / annual billing (spec: one plan); provisioning in the success handler (webhook only); enforcing the lapsed-subscription access rule (that is RLS/auth's job, wired in a later dispatch); real (live-mode) keys.
DON'T TOUCH: supabase/migrations/* (subscriptions table exists); the env hook; lib/auth.ts authorization logic; docs/*.
VERIFICATION:
- npm run typecheck && npm run lint && npm run build green.
- The webhook reads req.text() (raw body) and verifies with constructEventAsync; I will read the handler to confirm no req.json() before verification, and that the ONLY writer of subscriptions is the webhook.
- Lifecycle test green, and I will confirm it includes the FAIL, CANCEL, idempotent-replay, and bad-signature cases, not just a successful create.
- Operator: with stripe listen running, complete a test-mode Checkout and confirm the subscriptions row is written by the webhook (not the redirect); then stripe trigger invoice.payment_failed and see status flip to past_due.
Read STATUS.md and TECH-SPEC (subscription status enum, events) first. Do not invent event types; use the ones in the spec.
Where it breaks
The headline failure is provisioning on the redirect, walked in the cold open. The structural test for it: the subscriptions table has exactly one writer, the webhook. If any other route writes billing state, a closed tab or a forged success_url can lie to your database. The dispatch verification makes the operator confirm single-writer.
The second is reparsing the body before verifying. In the App Router this is subtle: a helper or logger that calls req.json() first consumes the stream, and even if you read text afterward the signature check fails or, worse, a well-meaning fix disables verification to make it pass. Read raw text first, verify, then parse from the verified event object.
The third is assuming event order or exactly-once delivery. Stripe guarantees neither. A handler that does if (status was active) then... based on a prior event it assumes arrived will corrupt state under reordering, and one that is not idempotent will double-apply under redelivery. Every handler keys off the field in its own payload and is safe to run twice; the event-id table makes the second run a no-op.
Lablab-4-7Capstone dispatch: Stripe subscriptions with a lifecycle gate
Goal: Integrate test-mode Stripe with the webhook as sole billing truth, and gate it on a lifecycle test that includes failure, cancellation, replay, and forgery.
Prereqs: the repo through 4.6, a free Stripe account in test mode, the Stripe CLI installed and stripe login done, one test-mode Product + $49/mo recurring Price created in the dashboard (copy its price_... id).
- Put your test-mode keys in
.env.local(secret key, price id, and APP_URL=http://localhost:3000). Do not commit them; the env hook and.gitignorealready prevent it. - In one terminal, run
stripe listen --forward-to localhost:3000/api/stripe/webhookand copy the printedwhsec_...into.env.localas STRIPE_WEBHOOK_SECRET. - Run the dispatch in a fresh session. At plan review, confirm the plan makes the webhook the only writer of the subscriptions table.
- Run VERIFICATION. Item 2 is the load-bearing read: confirm
req.text()before any parse,constructEventAsync, and single-writer. Item 4 is the live proof: complete a test Checkout (card 4242 4242 4242 4242), and watch the subscriptions row appear from the webhook, not the redirect. To prove that distinction, complete a checkout with the listener stopped: the redirect succeeds but no row is written, exactly as designed. Restart the listener and let Stripe redeliver. - Drive the failure path:
stripe trigger invoice.payment_failedand confirm status flips topast_due. Run the lifecycle test suite; confirm all cases green, especially the bad-signature and replay ones. - Close the loop: STATUS.md, CI green (lifecycle test runs in it against a signed fixture, no live Stripe needed), commit,
/clear.
Verify
- With the webhook listener stopped, a completed Checkout writes NO subscriptions row (redirect is not truth); with it running, the row appears from the webhook. You observed both.
- The webhook handler reads raw body via
req.text(), verifies before parsing, and is the only code that writes the subscriptions table. - The lifecycle test is green and includes create, payment_failed -> past_due, cancel, idempotent replay (same event id is a no-op), and bad-signature -> 400.
stripe trigger invoice.payment_failedmoved the row topast_duein the running app.
>Troubleshooting
- Signature verification always fails locally: something reparsed the body. Confirm the route reads
await req.text()and that no middleware or logger touches the request body first. The whsec must be the one this specificstripe listensession printed; it rotates per session. - The subscriptions row never appears after a real checkout: check the listener is forwarding to the exact path and the app is on the port you told
--forward-to. Look at thestripe listenoutput; it prints each event and your endpoint's response code. - Duplicate events corrupt the row: your idempotency insert is not actually gating (maybe you upsert the event id instead of insert-and-detect-collision, so it never collides). Insert with the event id as primary key and treat the unique-violation as "already processed, ack 200".
Knowledge check
Knowledge check
Sources
- Stripe Build a subscriptions integration (Checkout Session in subscription mode, provision on checkout.session.completed and invoice.paid, notify on invoice.payment_failed, portal session with return_url): https://docs.stripe.com/billing/subscriptions/build-subscriptions (fetched July 2026)
- Stripe Receive events in your webhook endpoint (raw body required for signature verification, return 2xx quickly, handle duplicate events by logging event ids, event ordering not guaranteed): https://docs.stripe.com/webhooks (fetched July 2026)
- Stripe Using webhooks with subscriptions (customer.subscription.updated/deleted for upgrades, downgrades, cancellations): https://docs.stripe.com/billing/subscriptions/webhooks (fetched July 2026)
- Stripe Integrate the customer portal with the API (billingPortal.sessions.create, return_url, listen for customer.subscription.updated): https://docs.stripe.com/customer-management/integrate-customer-portal (fetched July 2026)
- Stripe Trigger webhook events with the CLI (stripe listen --forward-to, whsec secret, stripe trigger EVENT): https://docs.stripe.com/stripe-cli/triggers (fetched July 2026)
- The Briefcase billing routes and lifecycle test are this course's reference artifacts, authored against the fetched Stripe docs.