Skip to content
Knowledge sections

Stripe billing integration

The env-gated billing implementation — plan display, per-seat pricing, seat sync, the Billing Portal, checkout handoff, entitlement gating, and the inbound webhook.

stripebillingseatswebhooks
On this page

Billing is an Optional Provider built on Stripe. Plan display, seat usage, and entitlement gating work with no Stripe configuration at all; only the provider touchpoints (checkout, the Billing Portal, seat sync, and the inbound webhook) activate when their environment variables are present. Everything else in the app keeps working when they are unset.

What exists where

  • Plan catalog: packages/capabilities/src/billing/plan-catalog.ts owns the PLANS constant, each plan's pricing shape (flat or per_seat), and its entitlement ceilings. The workspace billing page, the members page's seat prompt, and the entitlement gate read this one list. As rendered from the catalog:

    PlanPriceBilling shapeEntitlement ceilingsIncluded seatsHow you get on it
    Starter$0Flat2 API tokens · 1 webhook endpoint3Every workspace starts here
    Team$12/seat/moPer seatUnlimited— (per seat)Self-serve Stripe checkout
    EnterpriseCustomFlatUnlimitedUnlimitedSold outside the product

    The starter is MIT; these plans are the reference billing vocabulary the code ships with, not an offer.

  • Billing page: /workspaces/$slug/billing shows the current plan and catalog. With Stripe configured, owners get a Manage billing button that opens the Stripe Billing Portal (invoices, payment method, cancellation live there, not here), and can start a checkout for Team; Enterprise says "contact sales" and downgrades ride the provider's subscription flow. Unset, the button is absent and the page explains what to configure.

  • Seat usage: seatUsage(plan, memberCount) compares the roster against the plan's seat terms. On a flat plan past its included seats, the members page shows an upgrade prompt; members are never blocked, the workspace is asked to cover them. A per-seat plan never prompts; it bills one Seat Quantity per Member.

  • Seat sync: membership mutations (a Member added or removed) and invitation acceptances enqueue one message onto the BILLING_QUEUE. The background worker consumes it and mirrors the member count onto the Stripe subscription item's quantity through Billing.syncSeats, so a membership mutation never awaits Stripe. The stored quantity and a billing.seats_changed audit event commit together; a Stripe failure retries through the queue.

  • Checkout handoff: the startCheckoutServerFn server function composes success/cancel URLs server-side from BETTER_AUTH_URL (the client names only its slug and plan), calls Stripe's REST API directly over form-encoded fetch (no SDK), and records a billing.checkout_started audit event. On a per-seat plan the subscription item opens at the workspace's current member count.

  • Entitlement gate: assertWithinPlanLimit caps Starter workspaces at 2 API tokens and 1 webhook endpoint; paid plans do not cap. The web server functions and the API worker both compose it before creates, failing with a 402 PlanLimitExceeded.

  • Inbound webhook: POST /webhooks/stripe on the background Worker verifies Stripe's signature scheme against STRIPE_WEBHOOK_SECRET, then applies:

    EventWhat it does
    checkout.session.completedSets the plan from the session metadata and links the customer + subscription
    customer.subscription.createdRecords the seat item and reconciles the quantity (reconciliation source of truth)
    customer.subscription.updatedReconciles the seat quantity; a moved quantity writes billing.seats_changed
    customer.subscription.deletedDowngrades to Starter and detaches the seat item; the customer survives the portal

    Each plan change writes its billing.plan_changed audit event atomically with the update. This webhook is what heals any drift from a missed queue message.

Subscription state

workspace_subscriptions (one row per workspace) holds the Stripe customer the Billing Portal opens for, the subscription and subscription-item ids, and the quantity Stripe last reported. It is written only by the billing capability from provider events; a workspace without a row has never checked out, and the Manage-billing button tells them so if clicked before an upgrade.

Env gate

VariablePurpose
STRIPE_SECRET_KEYAPI key used for checkout, the Billing Portal, and seat-quantity updates
STRIPE_WEBHOOK_SECRETSigning secret of the webhook endpoint pointing at the background Worker
STRIPE_PRICE_ID_TEAMStripe price id for the self-serve Team plan (a per-seat Price)

All three are optional fields in packages/env/src/server.ts. Unset, checkout and the portal answer provider_not_configured (and the webhook route answers 503) while plan display, seat usage, and entitlements stay fully functional; syncSeats becomes an honest no-op.

Queue topology

Seat sync rides its own b2b-saas-starter-billing queue (produced by the web worker, consumed by the background worker) rather than sharing the webhook delivery queue: the message shapes and retry semantics are different. It has no dead-letter queue on purpose: sync is self-healing, so the next membership change re-syncs and the customer.subscription.updated webhook reconciles any drift. The full decision is recorded in ADR 0060 (docs/adr/0060-seat-based-billing-with-queue-decoupled-seat-sync.md).

Webhook setup

Point a Stripe webhook endpoint at <background-worker>/webhooks/stripe subscribed to checkout.session.completed, customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted. The handler matches Stripe's t=/v1= signature header scheme with HMAC-SHA256, rejects timestamps older than five minutes, and returns non-2xx on processing failure so Stripe schedules redelivery.

What Stripe is not used for

Stripe is not used for user authentication, not for invitation emails, and not for storing PII beyond what the subscription metadata carries (a workspace id and plan id). The starter's domain stays in D1, and the Billing Portal (not the starter) owns invoice, payment-method, and cancellation screens.