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).
- How it fits together
- Endpoints
- The Subscription object
GET /subscriptionsGET /users/{id}/subscriptionGET /organizations/{id}/subscriptionPATCH /organizations/{id}/subscription- Checkout
- Confirming a Checkout
- Portal
- Events
- 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: subscriptionEntitlement for them to each standard Application. When it stops covering them, those Entitlements end. Applications hear about every change through the usualaccess.granted/access.revokedwebhooks. - 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"
}
planmust bestandard.businessorenterprisereturns422 plan_requires_sales.- Allowed when there’s no subscription yet, or the existing one is
incomplete,incomplete_expired,canceled, orunpaid. Otherwise409 subscription_exists, and the subscriber should use the portal instead. - The first Checkout creates the Subscription with
status: incomplete, and writessubscription.created. - For an Organization,
member_scopedefaults toall_members, and Checkout’s quantity is the current covered-member count, at least 1. The individual form rejectsmember_scopeandmember_overrides. - Requires
Idempotency-Key.success_urlandcancel_urlmust behttps. A native app can use a universal link or app link. - Returning. The API adds
session_id={CHECKOUT_SESSION_ID}tosuccess_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…. Asuccess_urlthat 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 fromlocation.search. The client then calls confirm with that id. - Returning to
success_urldoesn’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 theGET …/subscriptionendpoint untilplanisstandard. - 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
openorexpiredsession 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_foundif 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. |