Billing
What this workspace is subscribed to, what the current period has accrued, and the statements behind it. It is account-management surface: no API key reaches any of it.
What is here
One overview, one profile write, and the statement history. Everything is read with the workspace's own credentials — there is no cross-tenant route here.
Session credentials only
All four routes are declared for sessionToken alone and require billing.manage. An API key cannot reach them at all, so hide the destination for a role without the permission rather than letting a request 403 — and re-check on the panel itself, because a bookmarked URL deserves an explanation rather than a failed request. See authentication.
# No API key reaches this: billing is account-management surface.
curl https://whats.azzamkh.sa/api/v1/billing \
-H "Authorization: Bearer <access token>" \
-H "X-Workspace-Id: ws_..."
# 200 OK — every amount is a MINOR-UNIT integer beside its own currency,
# and null means NOT PRICED, which is a different fact from 0.
# {
# "customer": {
# "id": "bcu_...", "provider": "manual", "name": "...",
# "email": "...", "currency": null, ...
# },
# "subscription": {
# "status": "active",
# "plan": { "code": "standard", "name": "Standard", ... },
# "pending_plan": null,
# "current_period_start": "2026-08-01T00:00:00.000Z",
# "current_period_end": "2026-09-01T00:00:00.000Z",
# "canceled_at": null,
# "items": [ ... ]
# },
# "latest_invoice": {
# "id": "inv_...", "number": null, "status": "open",
# "currency": null, "subtotal_minor": null, "total_minor": null,
# "unpriced_lines": 1, "period_end": "2026-09-01T00:00:00.000Z", ...
# }
# }Reading the money
- Every amount is a MINOR-UNIT INTEGER. There is no exponent to assume: JOD has three decimal places and JPY has none, so dividing by 100 anywhere is wrong for both.
- Each amount carries its OWN
currency. There is no deployment currency and no default — read the one beside the figure. nullmeans NOT PRICED, which is a different fact from0. A free plan legitimately costs zero; an unpriced line has never been given an amount.- Nothing is re-derived on the client. Not the period — the server anchors both boundaries on UTC midnights and clamps the day of month so a renewal cannot drift — not a line's amount, and not the total.
A total is only as complete as unpriced_lines says
total_minor sums the PRICED lines, and unpriced_lines counts the ones it left out. Render the count beside every total, or a customer reads the subscription fee as the whole bill. It is ordinarily at least 1 today: every subscription carries a metered line and no route prices usage yet, which is why this surface reads as a usage STATEMENT rather than as a bill.
The responses
Subscription
| Field | Description |
|---|---|
| status | The subscription's own status. |
| plan | The plan in force right now. |
| pending_plan | A decided-but-unapplied plan change. See below. |
| current_period_start, current_period_end | The current period's boundaries. period_end is EXCLUSIVE — do not shift it back a day to label it. |
| canceled_at | When it was cancelled, if it was. |
| items | The subscription lines, each with its own amount and currency. |
pending_plan is not a preference
It is a plan change parked to apply at the end of the period. The customer cannot cancel it and may not have asked for it, so it belongs above the screen rather than inside a card. effective_at is usually null — a change parked at period end has no instant of its own — so name the period end rather than printing an empty date.
Invoice
| Field | Type | Description |
|---|---|---|
| number | string | null | Minted at ISSUE, and deliberately not gap-free. An invoice here is not a tax document. |
| status | string | Its position in the invoice lifecycle. |
| period_start, period_end | string | The period it covers. period_end is exclusive. |
| subtotal_minor, total_minor | number | null | Both null when nothing on the invoice is priced. |
| unpriced_lines | number | How many lines the total left out. Always render it beside a total. |
| issued_at, due_at, paid_at, voided_at | string | null | When it was issued, is due, was paid, was voided. |
| lines | array | Present on the detail route; absent from the list. |
An invoice is FROZEN when its period closes: it keeps saying the same thing after a plan is edited or a price changes. Each line carries quantity, an optional metric, and unit_amount_minor and amount_minor that are null for anything unpriced.
Billing profile
PATCH /v1/billing/customer takes the name, email, tax id, country, currency and address. Every field is nullable, and sending null clears it. provider is read-only.
Changing plan
There is no self-service plan change, and that is deliberate: with the manual provider, collection happens outside this system, so an upgrade button would commit a customer to something the platform cannot complete. The plan catalogue is what a plan comparison is built from, and plans and quotas covers what the current plan actually allows.
Errors that matter here
| Code | HTTP | When it happens |
|---|---|---|
| BILLING_SUBSCRIPTION_NOT_FOUND | 404 | This workspace has no active subscription. |
| BILLING_INVOICE_NOT_FOUND | 404 | No invoice with that id in this workspace. |
| BILLING_INVOICE_INVALID_STATE | 409 | The invoice cannot move to that status from its current one. details.allowed carries the transitions that ARE permitted, and it is the authority — no endpoint publishes the table up front, so render what the refusal says. |
| BILLING_PROVIDER_UNAVAILABLE | 503 | The billing provider is unavailable. Retryable. |
| FORBIDDEN | 403 | The caller does not hold billing.manage. |
Every code and its status is on the errors page.