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_...The 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”In src/api/acl/, declare your plans. Example:
export const PLAN_CONFIG = { free: { stripePrice: env.STRIPE_PRICE_ID_FREE, features: ["basic-reporting", "one-team"], }, pro: { stripePrice: env.STRIPE_PRICE_ID_PRO, features: ["advanced-reporting", "unlimited-teams", "sso"], },};The ACL resolver checks this config and gates features based on the user’s subscription.
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.