Workflows
End-to-end sequences across the resources in the API Reference. If you’re adding a feature, check here first. It’s probably a step inside one of these, not a new flow.
- End user signs up inside an app (embedded login)
- Purchase → access
- Admin turns an app off for a user
- User is suspended
- Role change
- New user, first app
- Team purchase (org-wide grant)
- An org admin narrows who an org-wide grant reaches
- Developer upgrades their own plan (Starter → Team)
- A customer needs dedicated or regional infrastructure
- User asks for their data, then for erasure
- Settings resolution, in practice
End user signs up inside an app (embedded login)
The common first-contact path: someone downloads a developer’s app and creates an account from inside it.
sequenceDiagram
participant User
participant App as Application (client)
participant Backend as Application backend
participant Hub as Substratal API
User->>App: email + password
App->>Hub: POST /v1/auth/signup {email, password, application_id}
Hub-->>App: 201 AuthSession (email_verified=false)
Hub-->>User: verification email
App->>Backend: "new user usr_…" (developer's own call)
Backend->>Hub: POST /v1/users/{id}/entitlements {source: trial, ends_at} (app key)
Hub-->>Backend: 201 Entitlement (active)
App->>Hub: POST /v1/auth/app-tokens {application_id}
Hub-->>App: app_token (aud=app, effective_permissions)
- The app calls
POST /v1/auth/signup. The User, Profile, Settings, andmemberRole are created in one transaction, and the app gets a session immediately. - Signup grants nothing. The developer decides whether this user gets the app: free tier, trial, or nothing until they pay. Their backend grants an Entitlement with its app-scoped API Key.
- The app exchanges the platform session for an app token and verifies it locally from then on. The user’s AppProfile and AppSettings read as defaults until the app first writes them.
Purchase → access
Someone pays the developer for their app. Substratal never sees the payment; the developer’s backend reflects the outcome.
sequenceDiagram
participant User
participant Billing as Developer's billing (e.g. their Stripe)
participant Backend as Application backend
participant Hub as Substratal API
participant App as Application (client)
User->>Billing: pays
Billing->>Backend: payment succeeded (developer's own webhook)
Backend->>Hub: POST /v1/users/{id}/entitlements {source: purchase, order_id: sub_…}<br/>Idempotency-Key: <billing event id> (app key)
Hub->>Hub: insert Entitlement(active) + Audit Event + outbox events, one transaction
Hub-->>Backend: 201 (or 409 entitlement_already_exists → PATCH the existing one)
Hub-->>Backend: webhook access.granted
App->>Hub: POST /v1/auth/app-tokens (or oauth/token refresh)
Hub-->>App: app_token, entitlement_status=active
- The developer’s own billing system (see Orders & Audit → Billing system of record) tells the developer’s backend that a payment succeeded.
- The backend calls
POST /v1/users/{id}/entitlementswithsource: purchase, its own subscription id asorder_id, and its billing event id as theIdempotency-Key. A retried billing event can never double-grant.- If the user already holds a
trial, the backendPATCHes it instead:{"source": "purchase", "order_id": "sub_…", "ends_at": null}. - If the user holds a
disabledrow, the backend sets it back toactive.
- If the user already holds a
- The Entitlement, its Audit Event, and its webhook events commit together.
entitlement.grantedandaccess.grantedgo to the app’s subscriptions. - The next app token or introspection call reflects
active.
Reverse direction: a refund or a lapsed subscription on the developer’s side becomes PATCH /v1/entitlements/{id} {"status": "revoked", "disabled_reason": "refunded"}. A lapse at period end can also be modeled up front by setting ends_at, in which case the hub expires it automatically. Find the row with GET /v1/entitlements?order_id=sub_….
Admin turns an app off for a user
The “kill switch”: a support agent or admin disabling one user’s access to one app, without touching anything else about their account.
PATCH /v1/entitlements/{id}with{"status": "disabled", "disabled_reason": "..."}, ideally withIf-Match.- In one transaction, the hub writes an Audit Event (
entitlement.disabled, with actor, before/after, and reason) and enqueues webhook events. - The hub emits
entitlement.disabledand, if this was the user’s only active path to the app,access.revoked(reasonentitlement_disabled). - The app, subscribed to
access.revokedas it’s required to be, force-expires the user’s local session immediately. The app token’s 5-minute TTL is only the backstop. - Nothing about the user’s Role assignments, Profile, or Settings for that app changes. Re-enabling (
{"status": "active"}) restores exactly what was there before, and firesaccess.granted.
User is suspended
POST /v1/users/{id}/suspend(users.manage).- Every hub session is revoked. Platform tokens stop working on their next request, and refresh fails.
access.revoked(reasonuser_suspended) fires for every Application the user had active access to, anduser.suspendedfires to subscribers.- Entitlements are untouched.
PATCH {"status": "active"}reactivates the user and firesaccess.grantedfor each app they still have.
Role change
- An admin, the app’s owner, or the app’s own key calls
POST /v1/users/{id}/roles/{roleId}(assign) orDELETE(remove). effective_permissionsfor that user and that app changes immediately on the hub side.- The hub emits
role.assigned/role.removed. - Downstream, an assignment is picked up at the next app token (at most 5 minutes) or introspection call. A removal must take effect immediately:
role.removedis part of the required subscription set, and the app should drop the permission from the live session as soon as it arrives.
New user, first app
- An admin provisions the user:
POST /v1/userscreates the account (invitedby default). A default Profile and Settings are created, thememberplatform Role is assigned, and an invitation email is sent. - An admin or the developer’s backend grants an Entitlement (
admin_grant,trial, orpurchase). This is allowed while the user is stillinvited, and access starts once they activate. - The user accepts the invitation (
POST /v1/auth/invitations/accept), setting a password, and is signed in. - The first time the app reads the user’s AppProfile or AppSettings, it gets defaults. The first write creates the row. There’s no “provision this user in this app” step to orchestrate.
Team purchase (org-wide grant)
- A customer creates an Organization (self-service) and invites members.
- The customer pays the developer for a team plan. The developer’s backend calls
POST /v1/organizations/{id}/entitlementswith its app key:order_id, and optionallymember_scope. - Every included member gets access.
access.grantedfires once per member. New members added later are included automatically underall_members/denylist. - Seat changes on the customer’s side need no API call unless the developer limits seats with
member_scope: allowlist. Billing per member is the developer’s business; each included member counts as one Substratal seat for the developer’s own plan (see Pricing).
An org admin narrows who an org-wide grant reaches
- An org admin (
OrganizationMembership.role: org_admin) callsPATCH /v1/entitlements/{id}on their Organization’sorg_seatEntitlement, settingmember_scope: allowlistordenylistandmember_overrides. - The hub writes an Audit Event (
entitlement.member_scope_changed). It’s notentitlement.disabled/entitlement.granted, since the grant’s ownstatusdidn’t change. - Every affected member’s resolved access is recomputed. Nothing is backfilled, because there’s no per-member row to update.
access.revoked(reasonorg_grant_changed) fires for each member who lost their only path, andaccess.grantedfor each who gained one. A member excluded this way keeps any separate, personally-sourced Entitlement to the same app. See Access Control → Organization vs. User precedence.
Developer upgrades their own plan (Starter → Team)
- The Application owner calls
POST /v1/tenants/{id}/billing/checkout-sessionswithplan: teamand is sent to Stripe Checkout. - They pay. Stripe sends
checkout.session.completedandcustomer.subscription.createdto the hub’s Stripe webhook. - The hub sets
plan: teamandsubscription_status: activeon the Tenant and writestenant.plan_changed. Limits rise immediately. - The owner’s client polls
GET /v1/tenants/{id}/subscriptionuntilplanisteam.
A customer needs dedicated or regional infrastructure
Support-run today. See Tenancy → How a tier change happens today for why this isn’t self-service yet.
- A customer (an Application owner on the Enterprise plan) asks support for dedicated infrastructure, or a specific region for data residency.
- Support calls
POST /v1/tenants/{id}/tier-change-requests. See API Reference → Tenancy. - Support agrees a window with the customer and
PATCHes the request toscheduled. At the window, itPATCHes toin_progress, which flips the Tenant’sstatustomigratingand makes writes return503withRetry-After. Then it runs the snapshot/restore cutover described in Deployment Architecture → Tenancy tiers. Every Entitlement, AppProfile, AppSettings, and Audit Event row carrying thattenant_idmoves to the new infrastructure. PATCHtocompletedupdatestier/regionand returnsstatustoactive, and an Audit Event (tenant.tier_changed) records it.- Nothing about the customer’s Applications, Entitlements, or any end user’s access changes shape. This workflow only ever moves where the same rows live, never what they say.
User asks for their data, then for erasure
GET /v1/users/me/exportreturns everything as one JSON bundle.POST /v1/users/me/erasure-requestssoft-deletes immediately: sessions are revoked,access.revokedfires everywhere, and the email address is freed.- Seven days later, the hard-delete cascade runs and writes
user.erased.
Settings resolution, in practice
When an app needs a setting’s value for a user (for example, their notification-channel preference), the read order is always:
AppSettings override (this user, this app)
→ Settings (this user, global) — reserved keys only: locale, timezone, theme, notifications
→ Application's declared default (the property's JSON Schema `default`)
The hub resolves this server-side. GET /v1/users/{id}/apps/{appId}/settings returns the fully resolved object, plus where each value came from, so callers never re-implement the fallthrough. See Domain Model → Settings → Resolution rules for the exact algorithm.