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
- An Application is retired
- Role change
- New user, first app
- Team purchase (org-wide grant)
- An org admin narrows who an org-wide grant reaches
- A User subscribes to Standard
- An Organization subscribes to Standard for its members
- A customer needs dedicated or regional infrastructure
- An Application sends a push alert
- A User signs out, and back in, on a device
- 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 /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 /users/{id}/entitlements {source: trial, ends_at} (app key)
Hub-->>Backend: 201 Entitlement (active)
App->>Hub: POST /auth/app-tokens {application_id}
Hub-->>App: app_token (aud=app, effective_permissions)
- The app calls
POST /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 payment processor)
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 /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 /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 /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 /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 /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 /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 /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.
An Application is retired
PATCH /applications/{id}with{"review_status": "retired", "review_notes": "…"}(applications.manage). The Application must beapprovedorsuspended.- In the same transaction: its platform-managed Entitlements end and every other live one is revoked (
disabled_reason: application_retired), its API Keys are revoked, its webhook subscriptions and push credentials are disabled, and its devices become inactive. Test-mode rows go too. access.revoked(reasonapplication_retired) fires once per User who had access,application.review_status_changedfires to platform-scoped subscribers, andapplication.retiredis audited with counts of what was cut off.- App tokens can’t be issued for it again, and it drops out of
GET /applicationsunlessinclude_inactive=true. Its record, Roles, settings, audit history, and slug stay. There is no way back.
A test-mode Application that nothing references can instead be deleted with DELETE /applications/{id}.
Role change
- An admin, the app’s owner, or the app’s own key calls
POST /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 /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 /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 by email. Each invitation is a
pendingmembership until the person accepts it. - The customer pays the developer for a team plan. The developer’s backend calls
POST /organizations/{id}/entitlementswith its app key:order_id, and optionallymember_scope. - Every included member gets access.
access.grantedfires once per member. New members are included automatically underall_members/denylistonce they accept their invitation (apendingmembership grants nothing). - Membership 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.
An org admin narrows who an org-wide grant reaches
- An org admin (
OrganizationMembership.role: org_admin) callsPATCH /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.
A User subscribes to Standard
- On Free, the User already has every free Application. A standard one shows up in the catalog (
access_tier: standard) but doesn’t resolve to access. - Their client calls
POST /users/me/billing/checkout-sessionswithplan: standardand the User’s token, and sends them to the returned checkout URL. The hub creates their Subscription asincompleteand writessubscription.created. - They pay. The payment processor sends
checkout.session.completedandcustomer.subscription.createdto the hub’s billing webhook. The hub re-reads the subscription from the processor, sets itactive, and writessubscription.status_changed. - In the same transaction, the platform grants the User a
source: subscriptionEntitlement to each standard Application. Each writesentitlement.granted(actorsystem), andaccess.grantedfires for every Application the User didn’t already have. - The payment processor redirects them to
success_urlwithsession_id. The client callsPOST /users/me/billing/checkout-sessions/{session_id}/confirm, which applies the subscription right away if the webhook hasn’t yet, and gets backplan: standard. Steps 3 and 4 happen in whichever comes first, the webhook or confirm. - Later, they cancel in the billing portal. At period end the processor reports
canceled; the platform ends those Entitlements,access.revokedfires for each Application they no longer reach some other way, and the User is back on Free with their data intact.
An Organization subscribes to Standard for its members
- An org admin calls
POST /organizations/{id}/billing/checkout-sessionswithplan: standardand amember_scope:all_members, or anallowlist/denylistwithmember_overrides. Checkout’s quantity is the current covered-member count. - When the payment processor reports the subscription active, the platform grants the Organization one org-wide
source: subscriptionEntitlement per standard Application, with the subscription’s member selection.access.grantedfires once per covered member per Application. - Members who join later (once they accept) are covered automatically under
all_members/denylist. Members who leave lose access through it at once. - To change who’s covered, the org admin calls
PATCH /organizations/{id}/subscription. The platform updates the org-wide rows,access.*fires for whoever gained or lost access, andsubscription.members_changedis written. - Every day at 00:15 UTC the covered-member count is synced for billing. On a monthly subscription there’s no proration, so each invoice bills $6.99 per member covered the day before. On a yearly one, members added mid-year are charged a prorated part of $76.99 on an invoice that day.
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 business or enterprise terms arranged with Substratal) asks support for dedicated infrastructure, or a specific region for data residency.
- Support calls
POST /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.
An Application sends a push alert
A timesheet is due, and TimeTrack wants the User’s phone to say so.
sequenceDiagram
participant User
participant App as Application (client)
participant Backend as Application backend
participant Hub as Substratal API
participant Provider as APNs / FCM / Web Push
Note over Backend,Hub: once: the owner configures push credentials
App->>App: OS grants notification permission, issues a token
App->>Hub: POST /users/me/devices {application_id, provider, token} (user token)
Hub-->>App: 201 Device (tied to this session)
Backend->>Hub: POST /users/{id}/notifications {category, title, body, data}<br/>Idempotency-Key (app key, notifications.send)
Hub->>Hub: allow() + push preference, insert Notification + deliveries, one transaction
Hub-->>Backend: 201 Notification (push.status: queued)
Hub->>Provider: signed push, per device
Provider-->>User: notification on the lock screen
User->>App: opens it
App->>Hub: PATCH /users/me/notifications/{id} {state: read}
- Once, the Application’s owner configures its push credentials (an APNs key, an FCM service account, or a Web Push key pair). Credentials are managed only by people, never by an API Key.
- On launch, the client asks the OS for permission, gets a token, and registers the device. An embedded-login client does it with its user token, which ties the device to the session. A hosted-flow client passes the token to its backend, which registers it with the app key.
- The backend sends with its app key. The hub checks that the User has active access to TimeTrack and hasn’t turned push off, then writes the Notification and one delivery per active device in one transaction.
- A worker signs each push with TimeTrack’s credential and hands it to the provider. A token the provider reports as gone deactivates that device, and fires
device.deactivated. - The notification is in the User’s inbox whether or not a push arrived. When they open it, or the app shows it, the client marks it read, and its badge comes from
unread-count. - If no push could go out at all (no devices, no working credential, every device failed),
notification.push_failedtells the backend, which can fall back to email.
A User signs out, and back in, on a device
- The User signs out of TimeTrack. The app calls
POST /auth/logout. - The session is revoked, and every device registered under it becomes
inactive(session_revoked). Nothing more is pushed to that install, even though the OS token is still valid. - Someone else signs in on the same install, and the app registers the same token under their session. The token now belongs to them: the previous User’s Device is deleted (
device.deleted) and a new one is created for the new User. Neither User’s notifications ever reach the other. - If the first User signs back in instead, re-registering reactivates their existing Device.
An app that registers devices with its app key (no session) does step 2 itself: its backend calls DELETE /users/{id}/devices/{deviceId} when its own session ends.
User asks for their data, then for erasure
GET /users/me/exportreturns everything as one JSON bundle.POST /users/me/erasure-requestssoft-deletes immediately: sessions are revoked, push devices are deactivated,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 /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.