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_entitlementsstays 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,trialingorpast_dueis stopped (billing.client_is_stopped):get_widget_configreportsclient_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
activeuntil 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 untilcurrent_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
freethemselves (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¶
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¶
Plan changes, card updates, invoices and cancellation all happen in the Customer Portal and come back through the same webhook path.
Client billing states¶
| 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¶
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.
-
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_monthlyGrowth 600 USD rose_growth_monthlyThe 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.
-
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.
-
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-policyTerms of service https://userose.ai/terms-of-saleThe pages are published (IX-5082); the terms of sale link to the
https://userose.ai/data-processing-agreementDPA. Checkout requires the customer to accept the terms (consent_collection[terms_of_service]=requiredinstripe_client.py), so the checkbox only renders once the terms link is set here. Before live mode, confirm the copy has legal sign-off. -
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. -
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, andcustomer.subscription.deleted. See Stripe's webhook guide for delivery and signature troubleshooting. -
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_TESTtest restricted key from step 4 STRIPE_WEBHOOK_SECRET_TESTtest endpoint signing secret ( whsec_…) from step 5STRIPE_SECRET_KEY_STAGING/STRIPE_WEBHOOK_SECRET_STAGINGstaging credentials STRIPE_SECRET_KEY_PRODUCTION/STRIPE_WEBHOOK_SECRET_PRODUCTIONlive 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 createwithsecrets 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: leaveBILLING_AUDIENCEunset (staff) until launch, and setBACKOFFICE_BASE_URLto that environment's backoffice origin.Redeploy the admin API so it reloads the secret. Until the matching
STRIPE_SECRET_KEY_<ENVIRONMENT>exists,GET /billing/statusreportsenabled: falseand 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. -
Redeploy and verify. From
backend/, redeploy the environment so the admin API reads the latest secret versions:Run only the command for the environment being configured. Confirm its Cloud Run revision serves 100% of traffic,
GET /billing/statusreportsenabled: truefor 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/statusanswersenabled: falseto 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_billingrow (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.