Billing
One Stripe account and one webhook serve every product. Test mode end-to-end, so no real money is needed to verify billing.
Six ideas should not need six Stripe accounts. One Stripe account serves every product: a single webhook route verifies the signature once, deduplicates, and dispatches on metadata.product to the handler that product registered, falling back to the customer id for events that carry no metadata. One dashboard, one endpoint, one signing secret, however many products you add.
Cutlist is the worked product here: a free tier with caps, a $5/month pro plan, checkout, portal, and entitlement gating, all runnable in Stripe TEST mode.
Test mode is the default posture
The mode is decided purely by which key is set: sk_test_... is the sandbox, sk_live_... is real money. In test mode, checkouts, refunds, failed payments, and renewals are all free and unlimited.
Money-safety rails are built in: pnpm stripe:seed-test and pnpm stripe:scenarios hard-refuse to run against any non-sk_test_ key. You cannot point them at live by accident.
Setup (one time)
- Stripe dashboard, toggle test mode, copy the
sk_test_...key into.env.local:
STRIPE_SECRET_KEY=sk_test_...
Checkout is a server-created session the browser is redirected to, so the publishable key is not needed. Add it yourself if you build a surface with Stripe.js.
- Seed the test catalog (idempotent; creates the products and prices in test mode):
pnpm stripe:seed-test
It prints the env line to paste back into .env.local:
STRIPE_CUTLIST_PRO_MONTHLY_PRICE_ID=price_...
- For webhook-driven flows in the browser, forward events to your dev server:
stripe listen --forward-to localhost:3100/api/webhooks/stripe
It prints a whsec_...; set that as STRIPE_WEBHOOK_SECRET for the session.
The 4242 walkthrough
- Open http://cutlist.localhost:3100, sign in, and add pieces until you hit the free cap.
- Hit upgrade. Checkout opens in Stripe test mode.
- Pay with card
4242 4242 4242 4242, any future expiry, any CVC. - The webhook lands, the subscription row is upserted, and the cap lifts.
- Manage or cancel from the billing page (the Stripe customer portal).
Card 4000 0000 0000 0341 attaches but fails on charge, which exercises the dunning path.
The entitlement chain
Every paid feature is gated by the same chain, and the scenario suite asserts it end to end:
Stripe event
-> /api/webhooks/stripe (verify signature, idempotency via core.billing_events)
-> routeEvent resolves the product (metadata, then customer lookup)
-> the product's ProductBillingHandler
-> upsertSubscriptionFromStripe writes core.subscriptions
-> getEntitlement(orgId) reads it back
-> your feature gate
The pieces you write per product: an entitlements module (plan caps as exported constants so the pricing page and the gate share them), a checkout action calling createProductCheckout, a portal action calling createPortalSession, and a ProductBillingHandler registered in your register.ts. Everything else is chassis.
Never check status === "active" by hand; getActiveSubscription/isEntitlingSubscription own the semantics (trialing, plus the past-due grace window). One module decides what entitles, so changing that rule is one edit and every product has it.
The headless scenario suite
pnpm supabase:start
pnpm stripe:seed-test
pnpm stripe:scenarios
src/app/_products/testing/scenarios.itest.ts drives routeEvent directly (the exact code path the live webhook uses after signature verification) and asserts rows plus entitlements for the likely real-world flows: upgrade, cancel, failed renewal, refund. It self-skips when the local DB or a test key is absent, so keyless CI stays green. Add a scenario when you add a billing flow; it is part of pnpm test:integration.
Comp access without Stripe
grantManualSubscription({orgId, product}) writes an entitling subscription row with zero Stripe involvement; revokeManualSubscription(orgId) removes it. Use it to test entitled UI instantly, or to comp a real customer.
Going live
Swap the Vercel env to sk_live_..., the live publishable key, and the live webhook signing secret (add the webhook endpoint in the Stripe dashboard pointing at https://<your-apex>/api/webhooks/stripe). Create your live prices in the dashboard and set the live price id env vars. Test and live catalogs are fully separate; nothing you did in test mode touches production.
One endpoint, every product, every host. /api/webhooks/stripe is excluded from tenant rewriting and routeEvent resolves the product from metadata.product, never from the request host. A second endpoint per domain would only mint a second signing secret, and the code reads exactly one STRIPE_WEBHOOK_SECRET.
Which events to subscribe is not a guess. Two things consume the stream. routeEvent drops any event a registered handler did not declare in its events array, and emitBillingAnalytics runs product-agnostically on every claimed event. The dashboard selection must be the union. For the shipped cutlist handler plus the analytics layer, that is six:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
charge.refunded
Add an event to a handler and you must add it in the dashboard too, or the handler is silently never called. charge.refunded is the one that bites, because it only affects revenue reporting and any refund-clawback you add later, so nothing complains.
Price ids are product-specific, so nothing checks them for you. They are deliberately not in the chassis env schema, which means the boot report will not miss them and neither will pnpm doctor. STRIPE_CUTLIST_PRO_MONTHLY_PRICE_ID unset in the hosting env means the free tier keeps working and only the upgrade button is dead: the failure that survives longest unnoticed. Set every price id in the hosting env, not only in .env.example.