Skip to content

Self-serve billing (Stripe)

How self-serve clients pay for Starter and Growth, and how to set Stripe up for an environment. Design and rationale: docs/plans/2026-09-17-ix-5059-stripe-billing.md.

Model

  • Stripe owns the money state. config.self_serve_plan_entitlements stays the only thing Rose reads for plan, allowance and quota cycle. The admin API webhook (config.apply_stripe_subscription) is the only bridge between the two.
  • Billing belongs to the client, not the domain: one Stripe Customer per public.clients.id, at most one Subscription. Its plan is written to every domain of the client.
  • A client is linked once a subscription has been recorded (billing.client_billing.stripe_subscription_id IS NOT NULL). Free, sales-led and staff-assigned clients are not linked and billing never touches them.
  • A linked client whose subscription is not active, trialing or past_due is stopped (billing.client_is_stopped): get_widget_config reports client_enabled = false, so the widget does not mount on any domain of the client, exactly as for a former client. Its entitlement is left as is; it does not fall back to Free, and it cannot select Free itself. Resubscribing through Checkout brings Rose back at once; nothing is stored, the gate is derived.
  • A period the client paid for is always honoured. Cancelling in the portal ends the subscription at period end: Stripe keeps it active until then, so Rose keeps running on the paid plan and Plan & usage shows the end date. If a subscription the client asked to cancel is ended at once (portal set to cancel immediately, or cancelled by staff in Stripe), Rose still runs until current_period_end. A subscription ended for non-payment gets no such grace: its current period is the one that was never paid.
  • Clients can only select free themselves (config.select_self_serve_plan). Paid plans come from Stripe, or from staff in Clients → plan assignment.
Flow Path
Upgrade from Free, or resubscribe Plan & usage → Stripe Checkout
Change plan, card, invoices, cancel Plan & usage → Manage billing → Stripe Customer Portal
State sync Stripe → POST /billing/stripe-webhook on the admin API
Repair after a lost webhook GET /billing/status?refresh=true (sent by the Checkout return), or resend the event from the Stripe dashboard

The webhook never trusts the event payload for state. It re-reads the subscription from Stripe and mirrors it, so duplicate, late and out-of-order deliveries converge.

Diagrams

Who talks to whom

flowchart TD client([Client user]) --> bo["Backoffice<br/>Plan and usage"] bo -- "status · checkout · portal<br/>(user token)" --> api["Admin API<br/>/billing/*"] bo -. "browser redirect" .-> hosted["Stripe-hosted<br/>Checkout · Customer Portal"] hosted --- stripe[(Stripe)] stripe -- "signed webhook" --> api api -- "re-read subscription" --> stripe api -- "service-role RPC" --> mirror[(billing.client_billing)] mirror -- "same transaction" --> ent[(config.self_serve_plan_entitlements)] ent --> rose["Rose: plan, allowance, quota cycle"]

Rose never reads Stripe and never reads billing.* directly. The entitlement table is the only contract, and only the two RPCs behind the Admin API write to the mirror.

Upgrade from Free

sequenceDiagram autonumber actor U as Client user participant BO as Backoffice participant API as Admin API participant S as Stripe participant DB as Supabase U->>BO: Upgrade to Starter BO->>API: POST /billing/checkout-session API->>DB: get_client_billing (access on every domain of the client) opt first purchase API->>S: create Customer (idempotent per client) API->>DB: claim_stripe_customer end API->>S: create Checkout Session (idempotent per client, plan and billing state) API-->>BO: Checkout URL BO->>S: browser leaves for Checkout, user pays par webhook S->>API: checkout.session.completed / customer.subscription.* API->>API: verify signature and livemode API->>S: retrieve Subscription API->>DB: apply_stripe_subscription DB->>DB: mirror row + entitlement on every domain and return S-->>BO: /settings/plan-and-usage?checkout=success loop until serving, max 8 tries BO->>API: GET /billing/status?refresh=true end end BO-->>U: paid allowance and Manage billing

Plan changes, card updates, invoices and cancellation all happen in the Customer Portal and come back through the same webhook path.

Client billing states

stateDiagram-v2 [*] --> NotLinked NotLinked: Not linked<br/>Free, sales-led, staff-assigned Serving: Linked and serving<br/>active · trialing PastDue: Linked, past due<br/>Stripe retries the card Stopped: Linked, stopped<br/>canceled · unpaid · paused NotLinked --> NotLinked: abandoned or failed first Checkout NotLinked --> Serving: first subscription becomes active Serving --> Serving: plan change (quota cycle kept) Serving --> PastDue: payment fails PastDue --> Serving: payment recovered PastDue --> Stopped: Stripe gives up Serving --> Serving: cancelled, paid period still running Serving --> Stopped: cancelled, paid period ended Stopped --> Serving: resubscribe (new subscription, quota cycle restarts) Stopped --> NotLinked: staff deletes the billing row
State Entitlement Rose
Not linked whatever was selected or assigned runs; billing never touches it
Linked and serving, past due the subscribed plan runs
Cancelled by the client, paid period not over the subscribed plan runs until the period ends, then stops by itself
Linked, stopped unchanged, no fallback to Free stopped: the widget does not mount; Home and Plan & usage say "Rose is paused on your site" with Resubscribe

What the webhook does with an event

flowchart TD e[Stripe event] --> sig{"Signature valid<br/>and fresh?"} sig -- no --> r400[400] sig -- yes --> mode{"livemode matches<br/>this environment?"} mode -- no --> r400 mode -- yes --> kind{"checkout.session.completed or<br/>customer.subscription.* ?"} kind -- no --> ack["204, ignored"] kind -- yes --> read[Retrieve the subscription from Stripe] read --> known{"Rose client_id in metadata<br/>and a known price lookup key?"} known -- no --> ack known -- yes --> seen{"Subscription id<br/>already mirrored?"} seen -- "no, and not active" --> ack seen -- "yes, or active" --> apply["Mirror it. While active: write the plan<br/>to every domain of the client"] apply --> ok[204] read -. "Stripe or database failure" .-> retry["5xx, Stripe retries"]

The "not mirrored and not active" branch is what keeps an abandoned first Checkout from linking a client, and a late event for a replaced subscription from stopping a client who has already resubscribed.

Set up an environment

Prerequisites: Stripe account access and permission to create or update secrets in the inboundx GCP project. Use Stripe test mode for test and staging, live mode for production.

Stripe keeps test-mode and live-mode objects separate. Test and staging can reuse the test-mode catalog and default Customer Portal configuration, but each environment needs its own webhook endpoint and signing secret. Production needs live-mode products and prices, a live restricted key, a live Customer Portal configuration, and its own webhook endpoint. Stripe can copy products to live mode, but verify every copied price and lookup key before launch.

Environment Stripe mode and catalog API key secret Webhook secret Webhook URL
test Test; shared test catalog and portal STRIPE_SECRET_KEY_TEST (rk_test_…) STRIPE_WEBHOOK_SECRET_TEST https://admin-api-test-jsenzwq6tq-od.a.run.app/billing/stripe-webhook
staging Test; reuse the test catalog and portal STRIPE_SECRET_KEY_STAGING (rk_test_…) STRIPE_WEBHOOK_SECRET_STAGING https://admin-api-staging-jsenzwq6tq-od.a.run.app/billing/stripe-webhook
production Live; copy and verify the catalog, then configure the live portal STRIPE_SECRET_KEY_PRODUCTION (rk_live_…) STRIPE_WEBHOOK_SECRET_PRODUCTION https://admin-api-production-jsenzwq6tq-od.a.run.app/billing/stripe-webhook

Use a separate restricted key for test and staging when possible, even though both keys access the same Stripe test-mode data. This limits the impact of rotating or revoking one environment's key.

Production launch stop gates

Do not add the live key or enable client billing until the IX-5080 stop-gate migration is applied, the IX-5082 terms-of-sale page has legal sign-off and its URL is set in Stripe live-mode public details, and accounting has approved the live Stripe Tax configuration.

Supabase is shared by every environment

A test-mode event mirrored by a production-connected service would change a real client's entitlement. The webhook rejects any event whose livemode does not match the service (IX_ENVIRONMENT == production). Never point a test-mode webhook at admin-api-production.

That guard does not make test or staging safe for real clients: a test-mode subscription made there still writes its billing row and entitlement into production. Develop against a Supabase preview branch, and on test / staging use only a throwaway self-serve client you signed up yourself.

  1. Products and prices. Open the Stripe test product catalog or live product catalog. Create two products, each with one recurring monthly price. Set the price and lookup key exactly:

    Plan Monthly price Lookup key
    Starter 250 USD rose_starter_monthly
    Growth 600 USD rose_growth_monthly

    The code resolves prices by lookup key only, so amounts and currencies are changed in Stripe (create a new price and transfer the lookup key to it). Prices are immutable apart from limited metadata, so do not edit an existing subscription price in place.

  2. Stripe Tax. Follow Stripe's Tax setup guide, enable it in the selected mode, and set the company origin address. Checkout collects the billing address and VAT number. Confirm live registrations and tax treatment with accounting before production launch.

  3. Customer Portal and legal links. Follow Stripe's Customer Portal configuration guide. In Settings → Billing → Customer portal, allow switching between the Starter and Growth prices, cancellation at period end, payment method updates and invoice history. Test and live mode have separate portal configurations. Set the legal links under Settings → Business → Public details; Checkout and the portal both show them.

    Link Value
    Privacy policy https://userose.ai/privacy-policy
    Terms of service https://userose.ai/terms-of-sale

    The pages are published (IX-5082); the terms of sale link to the https://userose.ai/data-processing-agreement DPA. Checkout requires the customer to accept the terms (consent_collection[terms_of_service]=required in stripe_client.py), so the checkbox only renders once the terms link is set here. Before live mode, confirm the copy has legal sign-off.

  4. API key. Open Stripe's test API keys or live API keys page and create a restricted key with write on Customers, Checkout Sessions and Customer portal sessions, and read on Subscriptions and Prices. Follow Stripe's key security guidance; never use a publishable key (pk_…) or unrestricted secret key (sk_…) here.

  5. Webhook endpoint. Open Stripe's test webhooks or live webhooks page and create the endpoint using the exact URL from the environment table above. Select checkout.session.completed, customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted. See Stripe's webhook guide for delivery and signature troubleshooting.

  6. Secrets. Store Stripe credentials as standalone, environment-scoped GCP secrets. The admin API checks its process environment first (for local overrides), then reads the matching standalone secret directly:

    GCP secret Value
    STRIPE_SECRET_KEY_TEST test restricted key from step 4
    STRIPE_WEBHOOK_SECRET_TEST test endpoint signing secret (whsec_…) from step 5
    STRIPE_SECRET_KEY_STAGING / STRIPE_WEBHOOK_SECRET_STAGING staging credentials
    STRIPE_SECRET_KEY_PRODUCTION / STRIPE_WEBHOOK_SECRET_PRODUCTION live credentials

    Create the test API-key secret from the clipboard without printing it:

    pbpaste | tr -d '\r\n' | gcloud secrets create STRIPE_SECRET_KEY_TEST \
      --project=inboundx \
      --replication-policy=automatic \
      --data-file=-
    

    For later rotations, replace secrets create with secrets versions add. Repeat for the webhook secret and the other environments. Keep non-secret settings in the environment's backend configuration through the repository helpers: leave BILLING_AUDIENCE unset (staff) until launch, and set BACKOFFICE_BASE_URL to that environment's backoffice origin.

    Redeploy the admin API so it reloads the secret. Until the matching STRIPE_SECRET_KEY_<ENVIRONMENT> exists, GET /billing/status reports enabled: false and the backoffice shows no payment action at all (no billing card, no paused notice, and paid tiers on the onboarding plans screen open a sales draft), so deploying the code ahead of this step is safe.

  7. Redeploy and verify. From backend/, redeploy the environment so the admin API reads the latest secret versions:

    just deploy-admin-only test
    just deploy-admin-only staging
    just deploy-admin-only production
    

    Run only the command for the environment being configured. Confirm its Cloud Run revision serves 100% of traffic, GET /billing/status reports enabled: true for a staff user, and Stripe shows a successful delivery to the matching webhook endpoint.

Expected result: on a Free self-serve workspace, Plan & usage shows Upgrade your plan; paying in Checkout returns to Plan & usage, which shows the paid allowance and Manage billing within a few seconds.

Staff preview, then launch

Billing is a staff preview by default. While BILLING_AUDIENCE is unset or staff:

  • clients see nothing: no billing card on Plan & usage, no paused notice on Home, and paid tiers on the onboarding plans screen open a sales draft;
  • the API enforces it too: GET /billing/status answers enabled: false to a non-staff user, and the Checkout and Portal routes answer 403;
  • staff see every billing surface with the amber Staff badge, on any workspace they open, and can run the whole flow.

Launch to clients = set BILLING_AUDIENCE=clients in the environment's backend secret and redeploy the admin API. The badges disappear by themselves. It is a deliberate switch: a missing variable never opens billing.

While it is a staff preview, only subscribe throwaway clients. A subscription links the client for real: if it later ends, the stop gate pauses Rose on that client's site, and the client cannot see why.

Verify locally

Against a preview branch with the IX-5059 migration applied:

# from backend/ — local admin API with test-mode secrets in the environment
just dev-admin staging
stripe listen --forward-to localhost:8080/billing/stripe-webhook   # prints the whsec_ to use
# from frontend/client-backoffice
just dev-local-admin
Action Expected
Pay with 4242 4242 4242 4242 client linked; entitlement Starter; quota_started_at = subscription start
Switch to Growth in the portal plan changes, quota anchor unchanged
Cancel in the portal status stays active, Plan & usage says "Your subscription ends on …", widget still runs
Cancel immediately from the Stripe dashboard (reason: requested by customer) status canceled, is_stopped false until current_period_end; Plan & usage says "Rose keeps running until …"
Cancel, then advance a Stripe test clock past period end get_client_billing.is_stopped is true; entitlement unchanged; no widget on the demo page; Home says Rose is paused
Resubscribe from Plan & usage new subscription id, is_stopped false, quota anchor restarted, widget back
Abandon a first Checkout client stays unlinked and Free
stripe events resend <evt> twice same row

SQL checks: supabase/tests/ix5059_stripe_billing.test.sql and supabase/tests/ix5080_billing_stop_gate.test.sql.

Operations

  • See a client's billing link: Clients → open the client → Plan tab. The box above plan assignment says whether the client is linked, its Stripe status and period end, whether billing has stopped it, and warns that Stripe overwrites a manual assignment on a linked client.

  • Never give an environment live Stripe keys before the stop-gate migration (…_ix5080_billing_stop_gate.sql) is applied. Without it a client who cancels keeps the paid plan and a running widget indefinitely.

  • The stop is widget-side. The chat API still answers a direct call; the backend refusal belongs with quota enforcement (IX-5032), which should reuse billing.client_is_stopped.

  • Return a churned client to Free, or comp a linked client: delete the client's billing.client_billing row (after cancelling in Stripe), then assign the plan from the Clients page. There is no UI for unlinking.

  • A paying client looks unpaid: open Plan & usage (it refreshes from Stripe when the subscription is not serving), or resend the latest customer.subscription.* event from the Stripe dashboard. Check the endpoint's delivery log for 4xx/5xx.
  • stripe_customer_mismatch / "carries no Rose client_id" in the admin API logs: the subscription was created by hand in Stripe rather than through Checkout. It is acknowledged and ignored.