> ## Documentation Index
> Fetch the complete documentation index at: https://flow9.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Billing over the API

> 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.

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.

| Endpoint                         | What it answers                                                                                                                                                       |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/subscription`           | Which plan, what status, how many seats, when the trial ends, which modules are entitled, any scheduled downgrade.                                                    |
| `GET /v1/usage`                  | The monthly API-call quota (used / limit / remaining — the same numbers the `X-Quota-*` headers carry), the prepaid wallet balance, and consumable spend this period. |
| `GET /v1/checkout-sessions/{id}` | Whether a Stripe Checkout session you started completed, and — for a plan purchase — whether that plan is live yet.                                                   |

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.paid` →
   `stripe-webhook` mirrors the subscription and writes the entitlements.
4. `GET /v1/checkout-sessions/{session_id}` reflects each step within one poll:

```json theme={null}
{ "id": "cs_test_…", "kind": "subscribe", "status": "complete", "package_code": "pro",
  "activation": { "active": true, "subscription_status": "ACTIVE", "active_package_code": "pro" } }
```

* `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 `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 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:

```json theme={null}
{ "success": false, "code": "SEAT_LIMIT_REACHED",
  "seat_limit": { "active_users": 5, "limit": 5, "reason": "purchased" },
  "hint": { "action": "set_seats", "via": "manage-subscription", "seats_needed": 6 } }
```

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/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 under `billing:configure`; every `POST` honours `Idempotency-Key`. They reuse
the same Stripe logic the GUI's Plans tab uses.

| Call                                                           | Effect                                                                                                  |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `POST /v1/subscription/upgrade {package_code, billing_cycle?}` | Immediately, prorated.                                                                                  |
| `POST /v1/subscription/downgrade {package_code}`               | At period end. Refused while you hold more seats than the target includes.                              |
| `POST /v1/subscription/seats {seats}`                          | Seats above the included count are billed per seat.                                                     |
| `POST /v1/subscription/modules {module_key}`                   | Charges the card; answers `pending_webhook` — poll `GET /v1/subscription` until the module is `active`. |
| `DELETE /v1/subscription/modules/{module_key}`                 | Immediately or at period end; the response says which.                                                  |
| `POST /v1/subscription/cancel`                                 | Cancel at period end (below).                                                                           |
| `POST /v1/subscription/reactivate`                             | Undo a scheduled cancel.                                                                                |
| `GET /v1/invoices` (`billing:view`)                            | Last 24 invoices + the pending bill.                                                                    |

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.
