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
- 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/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/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. Stripe addssession_id. - Returning to
success_urldoes not by itself mean the subscription started. Coverage begins when Stripe’scheckout.session.completed/customer.subscription.createdwebhook arrives, usually within seconds. Clients should 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.
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. |