Billing

The Free/Standard subscription: a User subscribing to Standard for themselves, or an Organization subscribing for the members it covers, processed by Stripe. 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 /users/{id}/subscription
  6. GET /organizations/{id}/subscription
  7. PATCH /organizations/{id}/subscription
  8. Checkout
  9. Confirming a Checkout
  10. Portal
  11. Events
  12. 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.
  • Subscribing sends the person through Stripe Checkout, using a URL from this API. A User subscribes for themselves; an org admin subscribes their Organization.
  • Payment methods, invoices, and cancellation are handled in the Stripe Customer Portal, again through a URL from this API.
  • A Stripe Customer 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.

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

Endpoints

Method Path Requires Purpose
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 Stripe 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 Stripe’s webhook.
POST /users/me/billing/portal-sessions self, User token Open the Stripe Customer 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 Stripe 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 Stripe Customer 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" },
  "unit_amount": 600,
  "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 Stripe. 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 Stripe reports the new subscription. A first Checkout that’s abandoned becomes incomplete_expired when Stripe expires the session, 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 was last written to Stripe by the daily sync, which is what the next invoice charges for.
unit_amount Cents per user per month.

Invoices and receipts aren’t mirrored here. Stripe 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 in Stripe.

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 Stripe 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 /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",
  "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.stripe.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.
  • 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 Stripe fills it 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 Stripe’s 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 needs Stripe test mode. A deployment configured with a live Stripe key 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 from Stripe. If the session is complete, it applies the subscription immediately with the same sync the Stripe 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 Stripe has 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.stripe.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 Stripe live mode.
billing_unavailable 503 Stripe is unreachable, or billing isn’t configured. 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.