Billing
Stripe · Webhooks · Customer Portal · Stripe Tax
Billing is the highest-stakes pillar that's easiest to get wrong: every bug is either a refund or a lawyer. Lean on Stripe for the hard parts (PCI scope, dunning, tax, customer portal) and own the entitlement model in your own DB — a `subscriptions` table mirrored from webhooks that your code joins on for feature gates. Treat the webhook handler as a state machine, not a script.
Plan Catalog
The tiers and what each one unlocks. Defined in code (TypeScript) so feature gates are typed, mirrored to Stripe Products + Prices for billing. Single source of truth lives in the repo; deploy → sync script reconciles to Stripe.
How it flows
step by step- 01step
Engineer edits lib/billing/plans.ts — adds a feature flag entitlement.
- 02step
PR review: someone confirms pricing matches Notion/spreadsheet truth.
- 03step
Deploy → `pnpm sync:stripe-plans` reconciles to Stripe Products / Prices, never destructive.
- 04step
App reads from plans.ts (typed); Stripe sees the same prices on Checkout.
Cases & edge cases
watch for theseYearly + monthly are TWO Prices per Product. Map them by lookup_key, not internal ID.
Grandfathered pricing: never delete a Price, archive it. Old subs keep referencing it.
Per-seat plans: enforce seat counts on assignment, not on next renewal — customers HATE surprise overages.
Checklist
ship readiness- must
Plan catalog in code, typed
- must
Yearly + monthly variants per paid plan
- must
Per-feature entitlements map (not just plan name)
Where it lives
code references- lib/billing/plans.ts // typed catalog
- scripts/sync-stripe-plans.ts
Notes
marginIf your plan structure can't fit in one TS file, it's too complex. Start with 3 tiers max.
Subscription
Your `subscriptions` table mirrored from Stripe. Holds status, current_period_end, price_id, quantity, cancel_at_period_end. Every entitlement check joins on this row — not a Stripe API call.
How it flows
step by step- 01step
Stripe Checkout completes → customer.subscription.created webhook fires.
- 02step
Handler upserts (id, org_id, status, price_id, quantity, current_period_end).
- 03step
On customer.subscription.updated → reconciles same row.
- 04step
On customer.subscription.deleted → status = 'canceled' (don't delete the row, you'll lose history).
Cases & edge cases
watch for theseWebhook arrives BEFORE the redirect from Checkout → the success page must tolerate 'sub not yet visible' for a few seconds.
Status values: trialing, active, past_due, canceled, unpaid, paused, incomplete. Each gates differently.
Quantity > 1 (per-seat) — your seat-assignment UI MUST not exceed the subscription quantity.
Where it lives
code references- drizzle/schema/subscriptions.ts
- app/api/webhooks/stripe/route.ts:80 // subscription handlers
- lib/billing/entitlements.ts // joins sub → plan → features
Notes
marginIf you find yourself calling stripe.subscriptions.retrieve() on the hot path, your mirror is incomplete. Add the field and replay.
Stripe Checkout
Stripe's hosted (or embedded) payment page. You server-action a Checkout Session, redirect, Stripe handles cards/Apple Pay/Google Pay/SEPA/etc., and you get a customer + subscription on the other side via webhook.
How it flows
step by step- 01step
User clicks 'Upgrade to Pro' → server action createCheckoutSession({ priceId, orgId }).
- 02step
Action calls stripe.checkout.sessions.create with success_url, cancel_url, client_reference_id=orgId.
- 03step
Redirect to session.url (hosted) or render <EmbeddedCheckout sessionId=...>.
- 04step
On success → checkout.session.completed webhook fires (use this for fulfillment, not the redirect).
- 05step
Webhook upserts customer (if new) + subscription rows.
Cases & edge cases
watch for theseclient_reference_id = orgId is your way back from Stripe's customer to your tenant. Always set it.
checkout.session.completed is the canonical fulfillment trigger — webhooks survive client disconnects, redirects don't.
Embedded Checkout is the 2026 default for branded flows; hosted is fine for fastest ship.
Checklist
ship readiness- must
client_reference_id always set to orgId
- must
Fulfillment on webhook, not redirect
- should
Idempotency key on Checkout Session creation
Where it lives
code references- app/api/billing/checkout/route.ts
- lib/billing/stripe.ts
Notes
marginNever trust the redirect. Always fulfill on webhook. This is the single most repeated bug in Stripe integrations.
Stripe Webhook
One endpoint, ~12 event types you care about, signed by Stripe. This is where billing actually happens for your DB — every other code path is a read.
How it flows
step by step- 01step
Stripe POSTs to /api/webhooks/stripe with stripe-signature header.
- 02step
Handler verifies signature with STRIPE_WEBHOOK_SECRET (else 400 immediately).
- 03step
Inserts event row (id, type, payload) with PRIMARY KEY = stripe event id → idempotent by construction.
- 04step
Dispatches to typed handler for that event type (subscription.created, invoice.paid, ...).
- 05step
Returns 200 within 5s — Stripe retries with backoff up to 72h on non-2xx.
Cases & edge cases
watch for theseIdempotency by event.id (Stripe replays during outages). PK conflict = already processed.
Do the heavy lifting in a queued job, ack the webhook fast. 200 OK in <5s.
Whitelist event types — receiving an event you don't handle should NOT 500, just log + 200.
Test endpoint isn't just for Stripe — use the CLI: `stripe listen --forward-to localhost:3000/api/webhooks/stripe`.
Where it lives
code references- app/api/webhooks/stripe/route.ts
- lib/billing/webhook-handlers/
Notes
marginThe 12 events that matter: checkout.session.completed, customer.subscription.{created,updated,deleted}, invoice.{paid,payment_failed,upcoming,finalized}, customer.{created,updated}, payment_method.attached. Everything else is informational.
Customer Portal
Stripe's hosted page where users update card, cancel, change plan, download invoices. You build a 'Manage billing' button that creates a Portal Session and redirects. That's the entire UI.
How it flows
step by step- 01step
User clicks 'Manage billing' in /settings/billing.
- 02step
Server action: stripe.billingPortal.sessions.create({ customer, return_url }).
- 03step
Redirect to session.url.
- 04step
User makes changes → Stripe fires the relevant subscription.updated webhook.
- 05step
User clicks 'Return to <app>' → lands on return_url with state already updated.
Cases & edge cases
watch for theseConfigure the portal in Dashboard once: allowed actions (cancel, switch plan), products available, branding.
If a user cancels at-period-end in the portal, cancel_at_period_end=true on the sub — show countdown in your UI.
Tax IDs (B2B) are collected here, not by you — saves real legal headache.
Where it lives
code references- app/api/billing/portal/route.ts
Notes
marginResist the urge to build a custom billing UI for the first ~$1M ARR. The portal handles 95% of needs and you don't pay tax-compliance + dunning surface area.
Pricing Page
The marketing route that lists tiers and routes upgrade clicks to Checkout. Reads from the same lib/billing/plans.ts — single source of truth. Annual/monthly toggle is local state.
How it flows
step by step- 01step
Page renders from plans.ts at build time (or with revalidate=3600).
- 02step
User toggles annual ↔ monthly → swaps priceId per tier.
- 03step
Click 'Get Pro' → server action: createCheckoutSession(priceId).
- 04step
Already-signed-in users go straight to Checkout; logged-out users → /sign-up?next=/pricing&intent=pro.
Cases & edge cases
watch for theseLogged-out clicks need to preserve the chosen tier across sign-up.
Annual savings call-out ('Save 20%') needs to be a calculation, not a hardcoded string — drift kills trust.
A/B test plan names + prices behind a feature flag; pin existing customers to the version they saw.
Checklist
ship readiness- must
Reads from plans.ts, not hardcoded
- must
Logged-out → sign-up preserves intent
- should
Annual savings calculated, not hardcoded
Dunning
When a card fails, Stripe retries on a configured schedule (Smart Retries) and emails the customer. Your job: surface the past_due state in-app, give them a 1-click 'update card', and downgrade gracefully if recovery fails after N days.
How it flows
step by step- 01step
Stripe charge fails → invoice.payment_failed webhook → mark subscription status = past_due.
- 02step
App shows a banner: 'Your payment failed — update card to keep Pro access'.
- 03step
Stripe Smart Retries (configured in Dashboard) re-attempt 3-4 times over 14 days.
- 04step
If all retries fail → invoice.marked_uncollectible → subscription.deleted → status='canceled' → revoke entitlements (with a grace day).
- 05step
If user updates card → invoice.paid → status='active' → restore.
Cases & edge cases
watch for theseDon't immediately revoke on the first failure. Give them the recovery window — that's what dunning is.
Email is owned by Stripe in default config. Mirror their language in your in-app banner so users don't feel double-pinged.
Annual subs that fail are 12x more painful than monthly — consider a personal outreach playbook for >$500 invoices.
Where it lives
code references- app/(app)/components/billing-banner.tsx
- lib/billing/webhook-handlers/invoice-payment-failed.ts
Notes
marginSmart Retries recover ~38% of failed payments (Stripe's own number). Don't reinvent the schedule.
Tax Compliance
Stripe Tax calculates and collects sales tax / VAT / GST automatically based on customer location. Pay 0.5% per transaction; save weeks of nexus research. Enabled at the Checkout Session level.
How it flows
step by step- 01step
Enable Stripe Tax in Dashboard for the products you sell.
- 02step
At Checkout: pass automatic_tax: { enabled: true }.
- 03step
Stripe geolocates the customer → calculates tax → adds to invoice.
- 04step
Tax remittance: Stripe Tax (Standard) files in the US; you file internationally (until you upgrade tier).
Cases & edge cases
watch for theseEU B2B: customers enter VAT IDs in the Customer Portal → reverse charge applies (no VAT collected).
US: nexus tracking is automatic up to a threshold; over the threshold = you must register in that state.
Crossing $1M ARR triggers more states' nexus thresholds — review quarterly.
Decisions
pick once, live with itDecision 01
Stripe Tax vs. Paddle (merchant of record)?
pickStripe Tax for direct billing; Paddle / Lemon Squeezy if you want them to be MoR.
whyPaddle/Lemon Squeezy become the seller of record — they handle ALL tax + chargebacks + compliance in exchange for a higher cut (~5-8%). For solo founders, MoR is worth it; for funded teams, Stripe Tax is the cheaper path.
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.