Lesson 2 — Verify before you parse
The route handler reads the raw body once, verifies the signature with constructEvent, and answers 400 application/problem+json on failure, logging every disposition.
This project welds the last two chapters, webhook discipline and the Stripe billing model, into one runnable loop.
Inbound, three Stripe webhooks (checkout.session.completed, customer.subscription.updated, customer.subscription.deleted) reach one route handler that verifies, dedupes, and projects them into a single derived plan_entitlements row per organization.
Outbound, three methods behind lib/billing/ — upgrade, openPortal, requirePlan — let the app start a subscription, manage it, and gate paywalled content.
One rule joins the two sides: only the webhook writes the entitlement; everything else reads it.
You will prove the whole loop locally with stripe listen and stripe trigger, no deploy required.
A webhook handler runs unattended, retries on failure, and writes the state your customers pay for, so a partial or double-applied write costs money, not pixels. The skill here is not calling the Stripe SDK; it is making that seam safe:
last_event_at comparison lets a stale event no-op instead of overwriting newer state.plan_entitlements row; every other surface only reads it.Stripe.Subscription to exactly the columns your app needs.None of this is Stripe-specific; the same shape fits every async ingest you write next, from email-bounce webhooks to an internal event bus.
The loop runs in two directions over one rule.
Inbound — the webhook. Stripe sends an event to POST /api/webhooks/stripe. The handler reads the raw body, verifies the signature, claims the event in processed_events to dedupe, dispatches by event type, projects the Subscription into an entitlement patch, upserts plan_entitlements, and writes an audit_logs row before answering 200. Every write runs inside one db.transaction, all-or-nothing.
Outbound — the interface. The inspector’s buttons call upgrade and openPortal, Server Actions that return a Stripe-hosted URL: Checkout to start a subscription, the Customer Portal to manage it. requirePlan gates Server Components, reading the entitlement before paywalled content renders.
The contract between them. The webhook is the only writer for plan_entitlements; every other surface reads. When Checkout starts, the org’s id rides along on the Subscription’s metadata.organization_id, but that channel isn’t trusted alone. The authoritative source is resolveOrgIdFromCustomer, a reverse lookup from the Stripe Customer to the org that owns it. Lesson 6 cross-checks the two, so a forged organization_id cannot write to the wrong tenant.
Bold files are your work: each carries a TODO marker naming its lesson. Everything else ships complete; the subtrees you never touch are collapsed to one line.
sk_test_ prefix at bootplanEntitlements stub, PK-only — L4 adds the columnsorganization now carries stripeCustomerIdgetEntitlement, hasActiveAccess — L4getOrgWithOwnerEmail, setStripeCustomerIdstripe; apiVersion pinned '2026-05-27.dahlia'loadCatalog() typed loaderlookup_key → plan slug map, rewritten by seed:stripesubscriptionToEntitlement — L4upgrade Server Action — L5openPortal Server Action — L5requirePlan gate — L5BillingError classupgrade, openPortal, requirePlanresolveOrgIdFromCustomer — L3, L4, L6claimEvent, carried in from the webhook chapterpage.tsx + Poller.tsx, providedFour provided pieces the lessons build on:
lib/billing/stripe.ts configures the SDK singleton with the apiVersion pinned and STRIPE_SECRET_KEY from the typed env. Everything else goes through its three methods.catalog.json maps Stripe lookup_keys to app plan slugs; the loader’s planFromLookupKey(key) is the projection’s only path across that boundary, so raw price_id strings never appear in app code.claimEvent(tx, provider, eventId, eventType) is the check-and-claim dedupe helper from the webhook chapter. It returns true on a fresh insert and false when the unique(provider, eventId) constraint blocks a replay.inspector/page.tsx is where you watch behavior land. It renders the active org’s plan_entitlements row, processed_events tail, and audit log, the Checkout and Portal buttons, and dev-only debug controls: tamper a signature, replay an event, force out-of-order delivery, forge tenancy metadata.Each lesson adds one capability to the loop.
Lesson 2 — Verify before you parse
The route handler reads the raw body once, verifies the signature with constructEvent, and answers 400 application/problem+json on failure, logging every disposition.
Lesson 3 — Claim the event inside one transaction
Wraps the verified path in db.transaction, dedupes against processed_events, and stubs the dispatch switch so every event type is logged.
Lesson 4 — Project three events into one entitlement row
Completes the plan_entitlements schema, writes the pure projection, and adds the three handlers with the ordering predicate and an audit-log write on every transition.
Lesson 5 — Ship the three-method billing interface
Implements upgrade, openPortal, and requirePlan, wires the Checkout and Portal buttons, and runs the Stripe-hosted flow end to end with a test card.
Lesson 6 — Harden the webhook against forged tenancy
Adds the metadata cross-check so a forged organization_id can never write an entitlement to the wrong organization.
Install the Stripe CLI first; the steps depend on it.
Official install guide for the CLI that forwards live test events to your dev server.
Run these once, in order.
Get the starter codebase from the project repository, under Chapter 065/start/.
Copy the env template.
cp .env.example .envStart local Postgres; the project ships a docker-compose.yml with postgres:18. Wait for the container to report healthy.
docker compose up -dInstall dependencies. preinstall enforces pnpm and the engines field requires Node 24, so a wrong version fails fast.
pnpm installFill in .env (see the list below): generate the two secrets with openssl rand -base64 32 and paste your Stripe test-mode secret key. STRIPE_WEBHOOK_SECRET comes in step 8.
openssl rand -base64 32Apply the migrations: the prior project’s set plus processed_events, the plan_entitlements PK stub, and organization.stripe_customer_id.
pnpm db:migrateSeed the database: two orgs (Acme and Globex), four users (Alice, Bob, Carol, Dave), and one 'free' plan_entitlements row per org. Nothing calls Stripe yet, so each stripe_customer_id stays null.
pnpm db:seedAuthenticate the Stripe CLI, then open the forwarding tunnel in a second terminal and leave it running for the project.
stripe loginpnpm stripe:listenstripe listen prints a local signing secret (whsec_…); copy it into .env as STRIPE_WEBHOOK_SECRET.
Seed your Stripe account. This creates the pro and team Products with monthly Prices in test mode and rewrites src/lib/billing/catalog.json with their real lookup_keys. It finds-or-creates by lookup_key, so re-runs are no-ops.
pnpm seed:stripeStart the dev server. If it was already running when you pasted STRIPE_WEBHOOK_SECRET, restart it to load the new env.
pnpm devFour entries are specific to this project; the rest carry over unchanged.
STRIPE_SECRET_KEY — your test-mode secret key (sk_test_…), from the Dashboard under Developers → API keys in test mode. Server-only. src/env.ts validates the sk_test_ prefix at boot, so an sk_live_ key refuses to start.STRIPE_WEBHOOK_SECRET — the local signing secret (whsec_…) that stripe listen prints. It changes every CLI session, so re-paste it whenever you restart the CLI.STRIPE_PORTAL_RETURN_URL — http://localhost:3000/inspector. Where the Customer Portal returns the user after they manage their subscription.APP_URL — http://localhost:3000. The origin for Checkout’s success_url and cancel_url.DATABASE_URL (and _UNPOOLED), SEED, BETTER_AUTH_SECRET / URL, INVITATION_SIGNING_SECRET, RESEND_API_KEY, EMAIL_FROM / REPLY_TO, NEXT_PUBLIC_APP_NAME / URL.Open http://localhost:3000/inspector. The active org’s plan_entitlements row reads plan: free, and the Portal button is disabled: no Stripe Customer exists yet, so the Portal has nothing to manage.
The stripe listen terminal is your tunnel check: it prints 200 OK or 404 for every event it forwards. Fire one now:
stripe trigger checkout.session.completedIt returns 404, which is correct: the handler is still an empty stub. That 404 is the deliberate starting line; the next lesson turns it into a 200.