Recipe: Add Stripe Checkout
Add Stripe subscriptions and billing to your app. The API already ships a billing spine; this recipe wires it to real Stripe accounts for Checkout, the Customer Portal, and webhook handlers.
Estimated time: 45 minutes.
Overview
Section titled “Overview”- Set
BILLING_ENABLED=trueand add Stripe keys to env. - Forward webhooks locally with
stripe listen. - Extend the UI billing feature to call Checkout and Portal endpoints.
- Declare your plans in the ACL feature resolver.
Prerequisites
Section titled “Prerequisites”- A Stripe account (Test Mode is fine).
- One product and recurring price already created in the Stripe dashboard.
- The Stripe CLI installed locally.
1. Add Stripe env vars
Section titled “1. Add Stripe env vars”BILLING_ENABLED=trueSTRIPE_SECRET_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=whsec_... # from step 2 belowSTRIPE_PRICE_ID_FREE=price_...STRIPE_PRICE_ID_PRO=price_... # monthlySTRIPE_PRICE_ID_PRO_YEARLY=price_... # optionalThe env validator refuses to boot the API in prod if BILLING_ENABLED=true but any Stripe key is missing.
2. Forward webhooks locally
Section titled “2. Forward webhooks locally”Open a separate terminal:
stripe listen --forward-to localhost:7330/api/v1/billing/stripe/webhooksCopy the whsec_... signing secret that prints on first connect. Add it to compose/.env:
echo 'STRIPE_WEBHOOK_SECRET=whsec_...' >> compose/.envRestart the API:
./scripts/compose-up.sh3. Implement Checkout
Section titled “3. Implement Checkout”In apps/ui/src/features/billing/, the starter billing flow already exists. Add or adapt the Checkout call. Inside Billing.mutations.ts:
const startCheckout = (planId: string) => apiClient.POST("/api/v1/billing/stripe/checkout-session", { body: { planId, successUrl: `${window.location.origin}/billing/success`, cancelUrl: `${window.location.origin}/billing`, }, });The endpoint returns a Stripe Checkout URL; redirect the browser to it.
4. Implement the Customer Portal
Section titled “4. Implement the Customer Portal”Add a “Manage subscription” mutation that calls /api/v1/billing/stripe/portal-session and redirects to Stripe’s hosted portal.
const manageSubscription = () => apiClient.POST("/api/v1/billing/stripe/portal-session", { body: { returnUrl: `${window.location.origin}/billing`, }, });5. Configure your plans
Section titled “5. Configure your plans”Plans and their entitlements live in apps/api/src/api/billing/billing.plans.ts.
Pro has to give every key in FEATURES (apps/api/src/lib/acl/acl.constants.ts)
a value, and Free carries none, so it falls back to each feature’s default:
const PRO_FEATURES = { can_export: { bool: true }, can_invite_team: { bool: true }, max_seats: { number: PRO_SEAT_LIMIT },} satisfies PlanFeatureValues;Adding a feature key fails the type check and billing.plans.test.ts until Pro
has a value for it. The rows are rewritten from this catalog on every plan
read; per-account exceptions go in account_feature_overrides.
Verify
Section titled “Verify”- Log in and click Upgrade.
- Stripe Checkout redirects to your success URL.
- Use test card
4242 4242 4242 4242, any future date, any CVC. - The
stripe listenterminal shows webhook events arriving:checkout.session.completed,customer.subscription.created, etc. - Check the audit log for subscription events:
SELECT actor, event, payload->>'plan' AS planFROM audit.audit_logWHERE event LIKE 'billing.%'ORDER BY ts DESC LIMIT 5;- Hit
/api/me/featuresand verify the upgraded plan’s features return.
Key points
Section titled “Key points”- Idempotency matters: Stripe retries webhooks. The billing service uses an idempotency table keyed on Stripe’s
event.idto prevent double-processing. - Feature gates: Plans and features live in the ACL config. Change pricing later by editing env and ACL, not code.
- No UI secrets: The UI doesn’t need Stripe client IDs. All OAuth and Stripe calls go through the API.
Related
Section titled “Related”- Billing - the billing spine and webhook handlers.
- ACL & feature resolution - plan-based feature gating.
- Audit log - subscription lifecycle events.