Atribu
API Reference

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

Endpoint
GET /api/v1/workspaces/{workspaceId}/subscription

Scope: analytics:read · Credential: a Supabase session bearer

Success response (200 OK)
{
  "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

Endpoint
POST /api/v1/workspaces/{workspaceId}/checkout-session

Scope: analytics:read · Credential: a Supabase session bearer held by a workspace owner or admin

Request body
{ "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
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"
JavaScript
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.

Completed, after they pay
{
  "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

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

statuscodewhen
403insufficient_scopethe credential is an API key, or the caller is not an owner/admin
404not_foundthe workspace does not exist, or you are not an active member
422validation_errorunknown plan or interval
502provider_errorStripe refused — most often the plan's price does not exist in this Stripe mode. The message names the missing lookup key.
503service_unavailablebilling 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.

On this page