Billing
Read the plan and its limits, then mint a Stripe Checkout hand-off when the human needs to pay.
Two calls. GET .../subscription tells you what plan a workspace is on, how
close it is to its limits, and what can be sold to it.
POST .../checkout-session mints a hand-off carrying a
Stripe Checkout URL for a person to pay through.
Read the first before calling the second. Its limits are how you tell a human
why they need an upgrade instead of asking them to pay for a reason they
cannot see.
Read the subscription
GET /api/v1/workspaces/{workspaceId}/subscriptionScope: analytics:read · Credential: a Supabase session bearer
{
"data": {
"plan_tier": "starter",
"billing_interval": null,
"status": "active",
"billing_provider": "free",
"current_period_start": null,
"current_period_end": null,
"trial_started_at": null,
"trial_ends_at": null,
"cancel_at_period_end": false,
"canceled_at": null,
"limits": {
"active_profiles": { "used": 1, "max": 1 }
},
"upgrade_available": [
{
"plan": "growth",
"currency": "USD",
"price": "99",
"annual_monthly_price": "79",
"checkout": { "method": "POST", "path": "/api/v1/workspaces/{workspaceId}/checkout-session" }
},
{
"plan": "agency",
"currency": "USD",
"price": "349",
"annual_monthly_price": "279",
"checkout": { "method": "POST", "path": "/api/v1/workspaces/{workspaceId}/checkout-session" }
}
]
},
"meta": { "workspace_id": "2a87…" }
}limits.active_profiles carries the same used / max numbers
POST /api/v1/profiles compares before answering 403 profile_limit_reached,
so you see the ceiling coming rather than discovering it at a denial. max is
null when an override makes the cap moot — the workspace can never actually be
denied on that limit, so a finite number there would be a ceiling it cannot hit.
upgrade_available lists only what is above the current tier, cheapest
first. An empty array is a real answer with three possible meanings — already on
the top sellable plan, on a tier this catalogue does not rank (enterprise, whose
price is negotiated), or nothing above it. None of them should render an upgrade
prompt.
price is the monthly list price and annual_monthly_price the per-MONTH
rate when billed annually — the discounted figure the pricing page shows, not
the yearly total. Both are exact decimal strings in major units (ADR 0018);
parse before doing arithmetic.
They are LIST prices, not this workspace's effective price: a promotion code or a legacy price is settled at Checkout, and quoting a number this row cannot know would be worse than quoting the public one.
data is null — not a 404 — when the workspace has no subscription row at
all. That is a real state for a workspace that has never started a plan.
Mint a checkout hand-off
POST /api/v1/workspaces/{workspaceId}/checkout-sessionScope: analytics:read · Credential: a Supabase session bearer held by a
workspace owner or admin
{ "plan": "growth", "interval": "monthly" }plan is growth or agency. interval is monthly (the default) or
annual — an agent asking for "the Growth plan" has expressed a complete
intent, and requiring a cadence would turn a one-argument hand-off into a
two-argument one for no gain.
curl -X POST -H "Authorization: Bearer SESSION_TOKEN" \
-H "content-type: application/json" \
-d '{"plan":"growth"}' \
"https://api.atribu.app/api/v1/workspaces/2a87dfaf-…/checkout-session"const sub = await client.workspaces.subscription(workspaceId);
const [next] = sub?.upgrade_available ?? [];
if (next) {
const handoff = await client.workspaces.startCheckout(workspaceId, { plan: next.plan });
if (handoff.status === "pending") {
console.log("Ask them to pay here:", handoff.url);
}
}You get the same hand-off object every other kind returns. Give url to the
person who can pay; poll GET /api/v1/handoffs/{id} until status settles.
{
"data": {
"kind": "checkout",
"status": "completed",
"url": null,
"completed_at": "2026-09-05T11:02:31.000Z",
"result": { "subscription": { "tier": "growth", "status": "active" } }
}
}Stripe's webhook is what settles it, so result.subscription reports the row
your next GET .../subscription will actually read — not the one that was there
a moment before the sync. An abandoned checkout expires and the hand-off is
failed, with result.error: "checkout_expired".
Four things worth knowing
Already on that plan? completed, with no_change
{
"data": {
"kind": "checkout",
"status": "completed",
"url": null,
"result": { "no_change": true, "plan": "growth", "subscription": { "tier": "growth", "status": "active" } }
}
}Never a second subscription. A workspace mid-trial on that plan counts as being on it: it already holds that subscription, and buying it again would open a second one rather than convert the trial.
API keys cannot call this
The route is workspace-scoped, and an atb_live_ key is minted for exactly one
profile — it has no workspace membership to check, so 403. That is a property
of what a key is, not a scope anyone could grant. A signed-in session and an
MCP user token (atb_user_) both reach it — each names a PERSON with a
workspace role — and the MCP start_plan_upgrade tool rides this same route.
The caller must be an owner or an admin. An analyst holds the same API
scopes and is still refused: reading numbers is not committing the workspace to a
recurring charge.
Downgrades are not a checkout
A lower tier is a Stripe Customer Portal change. Opening a second subscription
for it would double-bill, which is why upgrade_available lists only what sits
above the current plan.
The Checkout URL is never handed back after settling
url on the hand-off is Atribu's own /h/<handle> link, which redirects to
Stripe while the hand-off is open and stops resolving once it is not. A spent
Stripe session renders an error page; a client that kept showing the link would
be advertising a dead payment.
Errors
| status | code | when |
|---|---|---|
403 | insufficient_scope | the credential is an API key, or the caller is not an owner/admin |
404 | not_found | the workspace does not exist, or you are not an active member |
422 | validation_error | unknown plan or interval |
502 | provider_error | Stripe refused — most often the plan's price does not exist in this Stripe mode. The message names the missing lookup key. |
503 | service_unavailable | billing is not configured on this deployment; no session was created |
A 502 also fails the hand-off rather than leaving it pending: a hand-off
with no URL behind it can only ever expire, and an agent polling it deserves the
error now.