Docs /deployment/stripe-billing

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 no STRIPE_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 stable lookup_keys. No card data touches our servers.
  • Webhook: packages/trpc/src/services/billing/stripeWebhook.ts + route apps/api/src/routes/webhooks.ts โ†’ POST /api/webhooks/stripe. Signature-verified; reconciles organizations.tier from 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-key via 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=true is 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.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.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 + index
  • 0022_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:

  1. Upgrade โ€” from the Usage page, Upgrade to a tier โ†’ complete Checkout with test card 4242 4242 4242 4242. Expect checkout.session.completed + customer.subscription.created โ†’ tier flips to the purchased tier.
  2. Manage โ€” Manage plan opens the portal; switch interval/tier โ†’ customer.subscription.updated โ†’ tier tracks the new price.
  3. Cancel โ€” cancel in the portal โ†’ on period end customer.subscription.deleted โ†’ tier returns to free.
  4. 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_due keeps access. isActiveSubscriptionStatus counts active, trialing, and past_due as 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 logs active subscription has unrecognised price lookup_key at error level rather than silently dropping the org to free. 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.

Reading this with an agent? /docs/deployment/stripe-billing.md serves the raw markdown.

All docs