Skip to main content
What a headless client can see and do about money, and the one pattern that matters: poll until it is live. All three reads take the 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:
  1. 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 to url.
  2. The customer pays on Stripe’s page. Stripe emits checkout.session.completed → the session row flips to complete.
  3. Stripe emits customer.subscription.created/updated and invoice.paidstripe-webhook mirrors the subscription and writes the entitlements.
  4. 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’s expires_at).
  • status: expired — the session lapsed (Stripe’s default is 24 h). Also reported for an open session past expires_at even 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’s GET /v1/subscription now shows the package and its modules.
kind: wallet_topup sessions have no activationcomplete 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 distinct 403 would confirm a foreign id exists.
  • A malformed id (not cs_…) is also a 404, never a 400, for the same reason.

Seats

Creating a user counts against the tenant’s seats: the package’s included_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:
Only active users hold a seat — deactivating one frees it. Enforcement applies to new creations only: a tenant already over its allowance keeps every user it has and is refused the next one. Billing-exempt tenants, and trial tenants that have not chosen a package yet, are not limited. GET /v1/subscriptionseats 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 under billing: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 — see docs/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 at current_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_status CANCELED, ): members can still sign in and an admin can choose a plan to reactivate it with everything intact. Until then API keys get 402 QUOTA_EXCEEDED with reason: subscription_ended (MCP connections get the same 402), modules are off in the app, and nothing sends or spends. GET /v1/subscription carries a dormant block with since, data_kept_until and days_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_DELETION and 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.