Billing

The Free/Standard subscription: a User subscribing to Standard for themselves, or an Organization subscribing for the members it covers, monthly or yearly. See Pricing for the tiers and rules. A third-party developer’s billing of their own users never touches this API (see Orders & Audit → Billing system of record).

  1. How it fits together
  2. Endpoints
  3. The Subscription object
  4. GET /subscriptions
  5. GET /billing/prices
  6. GET /users/{id}/subscription
  7. GET /organizations/{id}/subscription
  8. PATCH /organizations/{id}/subscription
  9. Checkout
  10. Confirming a Checkout
  11. Portal
  12. Events
  13. Errors specific to this resource

How it fits together

  • Free needs nothing. Every User is on Free from signup and has a platform-managed Entitlement to each free Application.
  • Prices come from GET /billing/prices: Standard per user per month, and per user per year. A client shows these rather than hard-coding amounts.
  • Subscribing sends the person through a hosted Checkout page, using a URL from this API, for the interval they choose. A User subscribes for themselves; an org admin subscribes their Organization.
  • Payment methods, invoices, and cancellation are handled in the billing portal, again through a URL from this API.
  • A billing account is created for a User or Organization the first time they check out, not at signup.
  • Access follows the subscription. While a subscription covers someone, the platform keeps a source: subscription Entitlement for them to each standard Application. When it stops covering them, those Entitlements end. Applications hear about every change through the usual access.granted / access.revoked webhooks.
  • Business and Enterprise plans aren’t offered yet. Checkout for either returns 422 plan_requires_sales.

The payment processor tells this API about every change through a webhook. This API never takes card data, and a subscription’s status only changes in response to the processor’s events. The handler is documented in the repository’s infra/README.md.

Endpoints

Method Path Requires Purpose
GET /billing/prices any User token Standard’s prices, monthly and yearly.
GET /subscriptions billing.manage Every subscriber’s subscription, filterable, for working the past_due list.
GET /users/{id}/subscription self, or billing.manage A User’s plan, what covers it, and their own subscription.
POST /users/me/billing/checkout-sessions self, User token Start a Checkout to subscribe yourself to Standard.
POST /users/me/billing/checkout-sessions/{session_id}/confirm self, User token On return from Checkout, apply the result now instead of waiting for the webhook.
POST /users/me/billing/portal-sessions self, User token Open the billing portal for your own subscription.
GET /organizations/{id}/subscription org_admin, or billing.manage The Organization’s plan and subscription.
PATCH /organizations/{id}/subscription org_admin Choose which members the subscription covers.
POST /organizations/{id}/billing/checkout-sessions org_admin, User token Start a Checkout to subscribe the Organization to Standard.
POST /organizations/{id}/billing/checkout-sessions/{session_id}/confirm org_admin, User token The Organization form of confirm.
POST /organizations/{id}/billing/portal-sessions org_admin or billing.manage, User token Open the billing portal for the Organization’s subscription.

Checkout and Portal act for a person, so they need a User token, not an API Key (400 user_token_required). An active member of the Organization who isn’t an org_admin gets 403; anyone else gets 404 organization_not_found.

The Subscription object

One row per subscriber, kept across lapses and resubscribes.

{
  "id": "sbs_01JAGC3D4E5F6G7H8J9K011M2N",
  "subscriber_type": "organization",
  "user_id": null,
  "organization_id": "org_01JAGB2C3D4E5F6G7H8J9K0L1M",
  "plan": "standard",
  "status": "active",
  "current_period_start": "2026-10-01T00:00:00Z",
  "current_period_end": "2026-11-01T00:00:00Z",
  "cancel_at_period_end": false,
  "member_scope": "all_members",
  "member_overrides": null,
  "quantity": { "current": 31, "last_synced": 30, "last_synced_at": "2026-10-17T00:15:03Z" },
  "interval": "month",
  "unit_amount": 699,
  "currency": "usd",
  "test_mode": false,
  "created_at": "2026-09-30T18:02:11Z",
  "updated_at": "2026-10-17T00:15:03Z"
}
Field Notes
id sbs_ prefix.
subscriber_type user (an individual subscription) or organization. Exactly one of user_id and organization_id is set.
plan Always standard today.
status active, trialing, past_due, canceled, unpaid, incomplete, incomplete_expired, or paused. Mirrors the payment processor. active, trialing, and past_due cover their users with Standard; the rest don’t. A first Checkout that hasn’t completed yet is incomplete. A new Checkout keeps the row’s current status until the new subscription is reported. A first Checkout that’s abandoned becomes incomplete_expired when the session expires, about 24 hours later. New values may be added; see Non-Functional Requirements → Versioning.
member_scope, member_overrides Organization subscriptions only: all_members, allowlist, or denylist, with the same meaning as on org-wide Entitlements. null on an individual subscription.
quantity.current Users billed, computed live. Always 1 for an individual subscription. For an Organization, its covered members: active Users with an active membership, included by member_scope, in the subscription’s own mode. See Pricing → What a “user” means for an Organization.
quantity.last_synced What the daily sync last reported for billing, which is what the next invoice charges for.
interval month or year: how often the subscription is billed.
unit_amount The subscribed price, in cents per user per interval, synced from the price actually subscribed to (699 monthly or 7699 yearly at today’s prices). While a Checkout is open it’s the price being bought.

Invoices and receipts aren’t mirrored here. The payment processor is the system of record for those, and the portal shows them.

GET /subscriptions

GET /subscriptions?status=past_due&status=unpaid
// Response — 200
{
  "data": [
    {
      "id": "sbs_01JAGC3D4E5F6G7H8J9K011M2N",
      "subscriber_type": "organization",
      "organization_id": "org_01JAGB2C3D4E5F6G7H8J9K0L1M",
      "status": "past_due",
      "...": "...",
      "user_email": null,
      "organization_name": "Acme Corp"
    }
  ],
  "page": { "next_cursor": null, "has_more": false }
}

Every User’s and Organization’s subscription, for billing.manage only. Anyone else, including org admins and the subscribers themselves, gets 403. Each item is the Subscription object plus the subscriber for display: user_email on an individual subscription, organization_name on an Organization’s, and null for the other. A deleted User’s subscription is still listed, but user_email is null unless the caller also holds users.manage. Results are in the caller’s mode, most recently updated first, and cursor-paginated. This is what the API already stores. Revenue, invoices, and payment attempts stay with the payment processor.

Filter Meaning
status Any of these statuses. Repeatable (?status=past_due&status=unpaid), up to 8. An abandoned first Checkout leaves an incomplete_expired row, so a console usually filters by status.
subscriber_type user or organization.
cancel_at_period_end true for subscriptions set to end at the period’s close, false for the rest.
current_period_end_before ISO 8601. Subscriptions whose current period ends before this time.

Ordering is by updated_at, which changes whenever the payment processor reports a change. A subscription updated while you page through can move to the front, so it can be missed or seen twice in one pass.

GET /billing/prices

// Response — 200
{
  "data": [
    { "plan": "standard", "interval": "month", "unit_amount": 699, "currency": "usd" },
    { "plan": "standard", "interval": "year", "unit_amount": 7699, "currency": "usd" }
  ]
}

Standard’s prices, for any signed-in User (a User token; an API Key gets 400 user_token_required). unit_amount is in cents per user per interval, and is exactly what Checkout charges for that interval, plus applicable taxes. The amounts are read from the payment processor and cached for a few minutes, so a price change shows up here shortly after it’s made. 503 billing_unavailable when billing isn’t configured or the prices can’t be read.

GET /users/{id}/subscription

// Response — 200, a User covered by their Organization
{
  "user_id": "usr_01JAGC3D4E5F6G7H8J9K0L1M2N",
  "plan": "standard",
  "plan_sources": [{ "type": "organization", "id": "org_01JAGB2C3D4E5F6G7H8J9K0L1M" }],
  "subscription": null
}

plan and plan_sources are the same read-only fields as on the User object. subscription is the User’s own individual subscription, if they’ve ever started one, and null otherwise. A User covered both individually and by an Organization lists both sources, so a client can suggest cancelling the individual one. Nothing is refunded automatically.

GET /organizations/{id}/subscription

// Response — 200
{
  "organization_id": "org_01JAGB2C3D4E5F6G7H8J9K0L1M",
  "plan": "standard",
  "subscription": { "id": "sbs_01JAGC3D4E5F6G7H8J9K011M2N", "subscriber_type": "organization", "status": "active", "...": "..." }
}

plan is standard while the subscription covers, and free otherwise. When a subscription exists, the response carries its ETag.

PATCH /organizations/{id}/subscription

// Request
{ "member_scope": "allowlist", "member_overrides": ["usr_01JAGC3D4E5F6G7H8J9K0L1M2N", "usr_01JAGD4E5F6G7H8J9K0L1M2N3P"] }

Changes which members the subscription covers. Only member_scope and member_overrides can be sent; anything else is 422 read_only_field. Every id in member_overrides must be a current active member (422 member_override_not_a_member). Send If-Match to avoid overwriting another admin’s change. Members gained or lost take effect immediately, firing access.granted / access.revoked, and the change is counted from the next daily sync. Returns 404 subscription_not_found if the Organization has never started a subscription.

Checkout

POST /users/me/billing/checkout-sessions (yourself) and POST /organizations/{id}/billing/checkout-sessions (an Organization).

// Request (the Organization form also accepts member_scope and member_overrides)
{
  "plan": "standard",
  "interval": "year",
  "success_url": "https://hub.substratalapps.com/billing?checkout=success",
  "cancel_url": "https://hub.substratalapps.com/billing?checkout=cancelled",
  "member_scope": "all_members"
}
// Response — 201
{
  "url": "https://checkout.example.com/c/pay/cs_live_a1B2c3D4...",
  "expires_at": "2026-10-05T13:00:00Z"
}
  • plan must be standard. business or enterprise returns 422 plan_requires_sales.
  • interval is month (the default) or year, and picks the price from GET /billing/prices. Anything else is 422 validation_failed. The subscription’s interval and unit_amount show the chosen price straight away.
  • Allowed when there’s no subscription yet, or the existing one is incomplete, incomplete_expired, canceled, or unpaid. Otherwise 409 subscription_exists, and the subscriber should use the portal instead.
  • The first Checkout creates the Subscription with status: incomplete, and writes subscription.created.
  • For an Organization, member_scope defaults to all_members, and Checkout’s quantity is the current covered-member count, at least 1. The individual form rejects member_scope and member_overrides.
  • Requires Idempotency-Key. success_url and cancel_url must be https. A native app can use a universal link or app link.
  • Returning. The API adds session_id={CHECKOUT_SESSION_ID} to success_url, and it’s filled in on the redirect, so the client lands on, for example, https://hub.substratalapps.com/billing?checkout=success&session_id=cs_test_a1B2…. A success_url that already contains {CHECKOUT_SESSION_ID} is left as it is. The parameter always goes in the query, before any # fragment, so a single-page app using hash routing reads it from location.search. The client then calls confirm with that id.
  • Returning to success_url doesn’t by itself mean the subscription started. Confirm settles it at once. Without confirm, coverage begins when the billing webhook arrives, usually within seconds, and a client can poll the GET …/subscription endpoint until plan is standard.
  • Test mode. A test-mode User or Organization gets a test-mode subscription, which is never charged and needs billing in test mode. A deployment that bills in live mode returns 409 test_mode_billing_unavailable.

Confirming a Checkout

POST /users/me/billing/checkout-sessions/{session_id}/confirm and POST /organizations/{id}/billing/checkout-sessions/{session_id}/confirm, with no body.

// Response — 200, the same body as GET /users/me/subscription, after the sync
{
  "user_id": "usr_01JAGC3D4E5F6G7H8J9K0L1M2N",
  "plan": "standard",
  "plan_sources": [{ "type": "user", "id": "usr_01JAGC3D4E5F6G7H8J9K0L1M2N" }],
  "subscription": { "id": "sbs_01JAGC3D4E5F6G7H8J9K011M2N", "status": "active", "...": "..." }
}

Call it when the person lands on success_url. It reads the Checkout Session. If the session is complete, it applies the subscription immediately with the same sync the billing webhook runs, including granting access. It then returns what the matching GET …/subscription returns (the Organization form also returns its ETag). So a client can show “You’re on Standard” on the very first screen after paying.

  • An open or expired session changes nothing; the response just shows the current state.
  • Safe to call more than once, and harmless if the webhook got there first.
  • 404 checkout_session_not_found if there’s no such session, or it belongs to another subscriber.
  • The Organization form is for an org_admin, like Checkout.

Portal

POST /users/me/billing/portal-sessions and POST /organizations/{id}/billing/portal-sessions.

// Request
{ "return_url": "https://hub.substratalapps.com/billing" }
// Response — 201
{ "url": "https://billing.example.com/p/session/live_YWNjdF8x...", "expires_at": "2026-10-05T12:05:00Z" }

The Portal is configured to allow updating the payment method, viewing invoices, and cancelling at period end. Covered members are changed with PATCH …/subscription, not in the Portal. A subscriber who has never completed a Checkout gets 409 no_billing_account.

Events

Subscription changes write Audit Events with target type subscription: subscription.created, subscription.status_changed, and subscription.members_changed. There are no outbound webhook events for billing itself. What Applications need to know, who gained or lost access, arrives as access.granted / access.revoked, along with the entitlement.* events for the platform-managed rows.

Errors specific to this resource

Code Status When
organization_not_found 404 {id} doesn’t resolve, or the caller isn’t a member and lacks billing.manage.
subscription_not_found 404 PATCH on an Organization that has never started a subscription.
plan_requires_sales 422 Checkout requested for business or enterprise.
subscription_exists 409 Checkout requested while a subscription is in force.
no_billing_account 409 Portal requested by a subscriber who has never completed a Checkout.
test_mode_billing_unavailable 409 Test-mode Checkout on a deployment that bills in live mode.
billing_unavailable 503 Billing isn’t configured, the payment processor is unreachable, or the prices can’t be read. Retry-After is set.
user_token_required 400 Checkout or Portal called with an API Key, or me used without a User token.
idempotency_key_required 400 Checkout without an Idempotency-Key.
forbidden 403 Another User’s subscription without billing.manage, or an Organization endpoint by a member who isn’t an org_admin.