billing:view scope.
Nothing here talks to Stripe on the request path. The API reads the local billing
mirrors that
stripe-webhook maintains, which is exactly why the last one has a
polling shape.
Poll until active
Stripe tells us about a payment asynchronously. The sequence is:POST billing-checkout{ action: "subscribe", package_code, billing_cycle }(session-token function today; exposes it on the public API) → returns{ url, session_id }. Send the customer tourl.- The customer pays on Stripe’s page. Stripe emits
checkout.session.completed→ the session row flips tocomplete. - Stripe emits
customer.subscription.created/updatedandinvoice.paid→stripe-webhookmirrors the subscription and writes the entitlements. GET /v1/checkout-sessions/{session_id}reflects each step within one poll:
status: open— the customer has not finished. Keep polling (a sensible cadence is every 3–5 s for a couple of minutes, then back off to the session’sexpires_at).status: expired— the session lapsed (Stripe’s default is 24 h). Also reported for anopensession pastexpires_ateven if the expiry webhook has not arrived yet. Start a new session.status: complete,activation.active: false— paid, entitlements not yet mirrored. This window is normally a few seconds. Keep polling.activation.active: true— done; the tenant’sGET /v1/subscriptionnow shows the package and its modules.
kind: wallet_topup sessions have no activation — complete is the end state
and GET /v1/usage shows the credited balance.
After add_module
manage-subscription add_module answers { status: "pending_webhook" }. Poll
GET /v1/subscription and look for your module in entitlements with
active: true (or simply in modules). Same cadence as above.
Errors
404 NOT_FOUND— the session id is unknown or belongs to another tenant. Deliberately the same answer: a distinct403would confirm a foreign id exists.- A malformed id (not
cs_…) is also a404, never a400, for the same reason.
Seats
Creating a user counts against the tenant’s seats: the package’sincluded_seats,
or the purchased seat count once bought (set_seats), whichever is larger. A
create that would exceed it is refused with SEAT_LIMIT_REACHED and a hint
naming the seat count to buy:
GET /v1/subscription → seats shows
where you stand.
Update the card, address or tax id
POST /v1/billing/portal-session (scope billing:configure, optional
{ "return_url": "https://…" }) returns a Stripe Billing Portal URL. Send the
customer there; the portal lets them change the payment method, contact and
address details and tax id, and see invoices. It cannot cancel, pause or
switch the plan — plan changes go through the subscription endpoints.
A workspace that has never checked out gets 409 NO_BILLING_CUSTOMER; run a
Checkout (subscribe or top-up) first. The GUI reaches the same thing through
billing-checkout { "action": "portal" }.
Change the plan, seats and modules
All underbilling:configure; every POST honours Idempotency-Key. They reuse
the same Stripe logic the GUI’s Plans tab uses.
Errors to branch on:
AGREEMENT_ACTIVE (409 — a negotiated agreement governs the
plan; talk to the account manager), CARD_CHARGE_FAILED (402 — fix the card via
the portal, retry), NO_ACTIVE_SUBSCRIPTION (409 — subscribe first), and
CONFLICT with details.reason for the rest (module_already_active,
package_not_configured, cannot_remove_last_item…).
Over OAuth these need the Billing consent group — opt-in, labelled “API &
SDK only”: there is deliberately no AI-assistant (MCP) tool that can spend money
or end a subscription.
Cancellation policy (plain language)
Decided 2026-09-04 (, option 2 — seedocs/decisions/hl-a5-cancel-vs-downgrade.md):
- You can cancel, at the end of the current period.
POST /v1/subscription/cancel(or the GUI) sets the subscription to end atcurrent_period.end. Nothing changes until then; there is no immediate cancel and no automatic refund. - You can change your mind until the period ends:
POST /v1/subscription/reactivate. - After the period ends the workspace is dormant (
billing_statusCANCELED, ): members can still sign in and an admin can choose a plan to reactivate it with everything intact. Until then API keys get402 QUOTA_EXCEEDEDwithreason: subscription_ended(MCP connections get the same 402), modules are off in the app, and nothing sends or spends.GET /v1/subscriptioncarries adormantblock withsince,data_kept_untilanddays_left. - Retention. Data is kept for 90 days after the subscription ends. A reminder
email goes out 7 days before that; at the end the workspace is moved to
PENDING_DELETIONand the normal tenant-lifecycle grace period and purge apply (with its own emails). Choosing a plan at any point before the purge keeps the data. - Downgrading to Basic remains the way to keep a workspace on the cheapest plan without ending it; request deletion remains the way to remove the data.
- Tenants on a negotiated agreement cannot self-cancel (
AGREEMENT_ACTIVE). - AI assistants cannot cancel — the action is not offered as an MCP tool.