Skip to content
BoringStack
GitHub

Recipe: Add Stripe Checkout

4 min read

Verified 2026-05

Recipes

Wire Stripe Checkout, the Customer Portal, and webhooks onto your local stack for subscriptions, billing updates, and feature gates.

45 min

Estimated duration

Stripe

Checkout + Portal

Webhooks

Idempotent handlers

Finish with a paid plan a user can subscribe to via Stripe Checkout, manage from the Customer Portal, and sync back into your DB through webhooks. The apps/api already ships api/billing/ with the right shape. This recipe wires it to a real Stripe account.

  • A working local stack (./scripts/compose-up.sh clean).
  • A Stripe account. Test Mode is fine; no business verification needed for this recipe.
  • One product + recurring price already created in the Stripe dashboard.
  • The Stripe CLI installed locally for webhook forwarding.
  1. Flip the feature flag and capture the Stripe secrets in compose/.env:

    Terminal window
    BILLING_ENABLED=true
    STRIPE_SECRET_KEY=sk_test_...
    STRIPE_WEBHOOK_SECRET=whsec_... # from `stripe listen` (next step)
    STRIPE_PRICE_ID_FREE=price_... # your free-tier price
    STRIPE_PRICE_ID_PRO=price_... # your paid-tier price

    BILLING_ENABLED=false is the default. The billing routes don’t mount until you flip it. The env validator refuses to boot the API in production if any required Stripe key is missing while BILLING_ENABLED=true.

  2. Forward webhooks to your local API:

    Terminal window
    stripe listen --forward-to localhost:3000/api/v1/billing/stripe/webhooks

    Copy the whsec_... signing secret printed on first connect into STRIPE_WEBHOOK_SECRET. Then restart:

    Terminal window
    ./scripts/compose-up.sh # picks up new env
  3. Extend the UI billing feature. apps/ui/src/features/billing/ already ships the page, queries, and mutations for the starter billing flow. Add or adapt the Checkout call site following the component anatomy. Inside Billing.mutations.ts, POST to the real endpoint via the typed client:

    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. Surface the Customer Portal. Add a “Manage subscription” mutation that POSTs to /api/v1/billing/stripe/portal-session and redirects the user to Stripe’s hosted portal for cancellation and payment-method updates.

  5. Add your plan to the ACL feature resolver. The accountId → activePlan → enabledFeatures chain is the source of truth. Adding a pro plan with a feature flag is one entry in the plan config.

  • Click Upgrade as a logged-in user. The browser redirects to Stripe Checkout. Use Stripe’s test card 4242 4242 4242 4242, any future date, any CVC.

  • After payment, the redirect drops you back at the configured success_url. The stripe listen terminal shows checkout.session.completed and customer.subscription.created events arriving.

  • In the API’s audit.audit_log table you should see entries for the subscription lifecycle (the billing service writes them fire-and-forget). Quick check:

    SELECT actor, event, payload->>'plan' AS plan
    FROM audit.audit_log
    WHERE event LIKE 'billing.%'
    ORDER BY ts DESC LIMIT 5;
  • The ACL endpoint /api/me/features returns the upgraded plan’s features.

  • apps/api/src/api/billing/billing.service.ts: fill in plan-to-price mapping and the webhook handler logic for checkout.session.completed.
  • apps/api/src/api/billing/billing.routes.ts: already exposes /stripe/checkout-session, /stripe/portal-session, /stripe/webhooks under the billing router; no new routes needed.
  • ACL plan config: declare your plan + features so the ACL resolver knows about it.
  • apps/ui/src/features/billing/: existing feature folder hosting the mutations that call the typed client and the page with the Upgrade button and Customer Portal link.

No new dependencies. No new patterns. Stripe is wired as a pluggable provider so changing pricing later is a config edit, not a code rewrite.