Stripe Billing Go-Live Runbook
Turning on paid subscriptions (#251). The billing code is merged (#390 + #392) but dormant until configured โ
isBillingEnabled()is false with noSTRIPE_SECRET_KEY, so the UI hides billing actions and the webhook route returns 503. This runbook is the wiring + verification to actually charge cards.
๐ฏ What's already built
- Service:
packages/trpc/src/services/billing/stripe.tsโ hosted Checkout + Customer Portal, Stripe customer per org, price resolution from stablelookup_keys. No card data touches our servers. - Webhook:
packages/trpc/src/services/billing/stripeWebhook.ts+ routeapps/api/src/routes/webhooks.tsโPOST /api/webhooks/stripe. Signature-verified; reconcilesorganizations.tierfrom subscription state. - tRPC:
organizations.createCheckoutSession/createPortalSession(owner/admin only). - Web: the org Usage page shows Manage plan (portal) when subscribed, else a monthly/annual toggle + Upgrade buttons.
organizations.tier (free / hobby / team / pro) stays the gating
source of truth; Stripe reconciles it.
โ Pre-launch checklist
1. Create products + prices in live Stripe
Each paid tier needs a price per interval, tagged with a stable
lookup_key. The code resolves prices only by these keys (never raw
price_โฆ ids), so they must match exactly:
| Tier | Monthly key | Annual key |
|---|---|---|
| hobby | izri_hobby_monthly |
izri_hobby_annual |
| team | izri_team_monthly |
izri_team_annual |
| pro | izri_pro_monthly |
izri_pro_annual |
Set the Lookup key field on each price (Dashboard โ Product โ price โ Advanced, or
--lookup-keyvia the API). Create the same six in test mode first for verification. Rotating a price later โ archive the old, create a new one with the same lookup_key โ needs no code or env change.
2. Activate the Customer Portal
Dashboard โ Settings โ Billing โ Customer portal: enable it and allow
plan switching + cancellation. createPortalSession fails until the
portal is configured (separately in test and live).
3. Set environment variables (prod / Railway)
| Variable | Value |
|---|---|
STRIPE_SECRET_KEY |
live secret key (sk_live_โฆ) |
STRIPE_WEBHOOK_SECRET |
signing secret of the prod webhook (step 4) |
ENFORCE_RUN_QUOTA |
true โ see warning below |
Also confirm APP_URL is the real public web origin: Checkout redirects
to ${APP_URL}/organizations/<slug>/usage?checkout=success|cancelled and
the portal returns to the same Usage page. A wrong APP_URL sends paying
users to a dead link after payment.
โ ๏ธ
ENFORCE_RUN_QUOTA=trueis not optional for a paid launch. Left unset/false, the run-quota gate only meters and warns โ the free tier is effectively uncapped, so paid tiers buy nothing. Flip it on at launch.
4. Register the webhook endpoint
Dashboard โ Developers โ Webhooks โ Add endpoint:
- URL:
https://<api-host>/api/webhooks/stripe - Events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted
Copy the endpoint's signing secret into STRIPE_WEBHOOK_SECRET
(step 3) and redeploy the API.
5. Apply the database migrations
The Stripe columns ship in two migrations โ apply both in prod:
0021_tranquil_jackpot.sqlโstripe_customer_id,stripe_subscription_id,stripe_subscription_status+ index0022_typical_tomorrow_man.sqlโstripe_cancel_at_period_end
pnpm db:migrate # runs outstanding Drizzle migrations
๐งช Verify against test Stripe first
Do a full dry run in test mode before pointing prod at live keys.
# Forward test webhooks to the local API (port 4000)
stripe listen --api-key sk_test_โฆ \
--forward-to localhost:4000/api/webhooks/stripe
# โ prints the whsec_โฆ to use as STRIPE_WEBHOOK_SECRET locally
Walk the lifecycle and assert organizations.tier after each step:
- Upgrade โ from the Usage page, Upgrade to a tier โ complete
Checkout with test card
4242 4242 4242 4242. Expectcheckout.session.completed+customer.subscription.createdโtierflips to the purchased tier. - Manage โ Manage plan opens the portal; switch interval/tier โ
customer.subscription.updatedโtiertracks the new price. - Cancel โ cancel in the portal โ on period end
customer.subscription.deletedโtierreturns tofree. - Replay/idempotency โ
stripe events resend <evt_id>lands the same row state (the webhook re-fetches the subscription fresh, so reconciliation is order- and replay-independent).
stripe trigger customer.subscription.deleted etc. can drive these
without real card flows.
๐ Behaviours worth knowing (so prod surprises don't look like bugs)
past_duekeeps access.isActiveSubscriptionStatuscountsactive,trialing, andpast_dueas active โ a card being retried doesn't instantly lose the paid tier. Dunning is left to Stripe; access drops only on terminal cancellation.- Unknown
lookup_keyโ downgrade. If an active subscription's price has no recognised lookup_key (a misconfigured price), the webhook holds the current tier and logsactive subscription has unrecognised price lookup_keyat error level rather than silently dropping the org tofree. If you see that log, fix the price's lookup_key in the dashboard (step 1). Watch for it after any price change. - Webhook failures retry. Any handler error (incl. transient DB
blips) returns non-2xx, so Stripe retries โ expected. A persistent 400
means a bad signature (wrong
STRIPE_WEBHOOK_SECRET) or unconfigured billing (503).
๐ Rollback
Billing is feature-flagged by config: unset STRIPE_SECRET_KEY (and
redeploy) to make the module dormant again โ UI hides billing, the
webhook 503s. Existing organizations.tier values are untouched, so
already-provisioned orgs keep their tier until you reconcile manually.
The migrations are additive (nullable columns) and need no rollback.