Applications
See Domain Model → Applications for the full field reference.
- Endpoints
- Who can see an Application
- The Application object
GET /v1/applicationsGET /v1/applications/{id}POST /v1/applicationsPATCH /v1/applications/{id}- Errors specific to this resource
- Owner onboarding, end to end
Endpoints
| Method | Path | Requires | Purpose |
|---|---|---|---|
GET |
/v1/applications |
any authenticated caller | List the catalog, filtered by visibility rules. |
GET |
/v1/applications/{id} |
any caller who can see it | Fetch one Application. |
POST |
/v1/applications |
applications.manage |
Register a new Application. Platform-admin only through Phase 2. |
PATCH |
/v1/applications/{id} |
the owner (configuration fields) or applications.manage (anything) |
Update a catalog entry. |
Who can see an Application
visibility decides who gets an Application from GET /v1/applications and GET /v1/applications/{id}. A caller who can’t see one gets 404 application_not_found on a direct fetch, so its existence isn’t leaked.
visibility |
Listed / fetchable by | Who can grant an Entitlement to it |
|---|---|---|
public |
Every authenticated caller, as long as review_status: approved. |
Platform entitlements.manage, the app’s own confined key, or the app’s owner. |
invite_only |
Users with any access path to it, its owner, its own app key, and applications.manage. Access paths come from grants made by the parties in the next column, so nobody needs to see the app before being granted it. |
Same as public. |
internal |
Only applications.manage holders and its own app key. |
Platform entitlements.manage only. |
Applications that aren’t approved are visible only to their owner, their own key, and applications.manage, whatever their visibility.
The Application object
{
"id": "app_timetrack",
"slug": "timetrack",
"name": "TimeTrack",
"description": "Time tracking and timesheet export for teams.",
"icon_url": "https://assets.substratalapps.com/apps/timetrack/icon.png",
"launch_url": "https://timetrack.substratalapps.com/sso/launch",
"redirect_uris": [
"https://timetrack.substratalapps.com/oauth/callback",
"com.substratal.timetrack:/oauth/callback"
],
"support_url": "https://timetrack.substratalapps.com/support",
"email_from_name": "TimeTrack",
"settings_schema": {
"type": "object",
"properties": {
"default_billable": { "type": "boolean", "default": true },
"week_start": { "type": "string", "enum": ["sunday", "monday"], "default": "sunday" },
"invoice_footer": { "type": "string", "maxLength": 500, "x-pii": true }
}
},
"settings_schema_stats": { "stale_override_counts": {} },
"permissions": [
{ "key": "app.timetrack.export", "description": "Export timesheets as CSV/PDF." },
{ "key": "app.timetrack.manage_members", "description": "Add and remove team members inside TimeTrack." }
],
"available_app_roles": ["admin", "member"],
"default_app_role": "member",
"visibility": "public",
"owner_user_id": "usr_01JAG0SYSTEM00000000000000",
"owner_organization_id": null,
"review_status": "approved",
"review_notes": null,
"tenant_id": "tnt_01JAG1SYSTEM00000000000000",
"test_mode": false,
"created_at": "2025-11-03T00:00:00Z",
"updated_at": "2026-09-01T00:00:00Z"
}
| Field | Rules |
|---|---|
id |
Read-only and derived: app_ plus slug (see Conventions → IDs). |
slug |
Matches ^[a-z][a-z0-9-]{1,63}$ (no _), isn’t platform (reserved), is unique across live and test mode, and is immutable once set. It’s embedded in the id, permission keys (app.<slug>.*), and Role ids (role_<slug>_<name>, verbatim). |
name |
1–100 characters. |
launch_url |
https URL. Where the future dashboard sends a user to open the app. |
redirect_uris |
0–10 entries, each an exact-match string: an https:// URL, or a private-use URI scheme for native apps (reverse-DNS, com.example.app:/path). http://localhost and http://127.0.0.1 with any port are allowed for development only on a test_mode Application. Required (non-empty) before the hosted authorization-code flow will issue a code — see Auth. |
support_url, email_from_name |
Optional. Used in transactional emails sent on behalf of this app. email_from_name is 1–50 characters; the sending address is always Substratal’s own. |
settings_schema |
See Domain Model → Settings → settings_schema rules. |
settings_schema_stats |
Read-only. stale_override_counts is a key → count map of how many users hold overrides that no longer validate. |
permissions |
The Application’s own permission catalog, 0–100 entries. Each key must start with app.<slug>., then match [a-z0-9_.]{1,64}. description is up to 255 characters. This is what GET /v1/permissions lists, and it’s the only set an AppRole may draw from. |
available_app_roles |
The role-name vocabulary: 0–20 names, each matching ^[a-z][a-z0-9_]{1,40}$. Every app-scoped Role for this Application must use one of these names. |
default_app_role |
null, or one of available_app_roles. If set, every user with active access to the app implicitly holds the app-scoped Role with that name (role_<slug>_<name>, always present because it’s seeded with the name), without an assignment row. |
tenant_id |
Read-only. Resolved from the owner’s Tenant at creation, and the Tenant is created if needed. |
GET /v1/applications
// Response — 200
{
"data": [
{
"id": "app_timetrack",
"slug": "timetrack",
"name": "TimeTrack",
"description": "Time tracking and timesheet export for teams.",
"icon_url": "https://assets.substratalapps.com/apps/timetrack/icon.png",
"visibility": "public",
"review_status": "approved"
}
],
"page": { "next_cursor": null, "has_more": false }
}
List responses return a trimmed view. They leave out settings_schema, permissions, redirect_uris, and owner fields, so fetch the single resource for the full object.
| Filter | Who may use it |
|---|---|
visibility |
Anyone, applied within what they can already see. |
review_status |
Anyone, but non-admins only ever see approved apps plus their own. ?review_status=pending_review with applications.manage is the review queue, oldest first. No separate queue resource exists. |
owned=true |
Only Applications the caller owns (directly, or as org_admin of the owning Organization). |
tenant_id, owner_user_id, owner_organization_id |
applications.manage or tenants.manage. |
GET /v1/applications/{id}
Returns the full Application object above.
POST /v1/applications
// Request
{
"slug": "invoicer",
"name": "Invoicer",
"description": "Send and track invoices.",
"launch_url": "https://invoicer.substratalapps.com/sso/launch",
"redirect_uris": ["https://invoicer.substratalapps.com/oauth/callback"],
"settings_schema": { "type": "object", "properties": { "currency": { "type": "string", "enum": ["USD", "EUR"], "default": "USD" } } },
"permissions": [ { "key": "app.invoicer.view", "description": "View invoices." }, { "key": "app.invoicer.send", "description": "Send invoices." } ],
"available_app_roles": ["admin", "member"],
"default_app_role": "member",
"visibility": "public",
"owner_user_id": "usr_01JAG0SYSTEM00000000000000"
}
// Response — 201
{
"id": "app_invoicer",
"slug": "invoicer",
"name": "Invoicer",
"description": "Send and track invoices.",
"icon_url": null,
"launch_url": "https://invoicer.substratalapps.com/sso/launch",
"redirect_uris": ["https://invoicer.substratalapps.com/oauth/callback"],
"support_url": null,
"email_from_name": null,
"settings_schema": { "type": "object", "properties": { "currency": { "type": "string", "enum": ["USD", "EUR"], "default": "USD" } } },
"settings_schema_stats": { "stale_override_counts": {} },
"permissions": [ { "key": "app.invoicer.view", "description": "View invoices." }, { "key": "app.invoicer.send", "description": "Send invoices." } ],
"available_app_roles": ["admin", "member"],
"default_app_role": "member",
"visibility": "public",
"owner_user_id": "usr_01JAG0SYSTEM00000000000000",
"owner_organization_id": null,
"review_status": "approved",
"review_notes": null,
"tenant_id": "tnt_01JAG1SYSTEM00000000000000",
"test_mode": false,
"created_at": "2026-10-05T12:00:00Z",
"updated_at": "2026-10-05T12:00:00Z"
}
The Roles role_invoicer_admin and role_invoicer_member now exist with no permissions.
- Requires
applications.manage. Through Phase 2 this is how every Application comes to exist, including a paying customer’s. Staff create it with the customer as owner, and the owner manages it from then on (“concierge onboarding”). slug,name,launch_url, and exactly one ofowner_user_id/owner_organization_idare required. Everything else is optional.- Resolves the owner’s Tenant, creating one if needed. A new Tenant gets
tier: shared, andplan: starterfor a User owner orplan: teamfor an Organization owner. - Seeds the app’s Roles. For each name in
available_app_roles, a Rolerole_<slug>_<name>is created with no permissions, and the owner then fills them in withPATCH /v1/roles/{id}. Because every app-scoped Role must use a name fromavailable_app_roles, the plan’s AppRoles limit (Starter 3, Team and Enterprise unlimited) is simply a cap on the length ofavailable_app_roles. Exceeding it onPOSTorPATCHreturns409 plan_limit_reached(resource: "app_roles"; see Conventions → Plan limit errors). - Rejected with
409 plan_limit_reachedif the owner’s Tenant is already at its plan’s Applications cap (1/5/unlimited). Rejected with402 subscription_requiredif the Tenant isrestricted. Both are independent of the caller’s own permission. See Pricing → Enforcement. - Writes
application.created.
PATCH /v1/applications/{id}
// Request — owner adding a role name and a permission
{
"available_app_roles": ["admin", "editor", "member"],
"permissions": [
{ "key": "app.timetrack.export", "description": "Export timesheets as CSV/PDF." },
{ "key": "app.timetrack.manage_members", "description": "Add and remove team members inside TimeTrack." },
{ "key": "app.timetrack.approve", "description": "Approve submitted timesheets." }
]
}
// Response — 200, the full updated Application (same shape as above), now with
// "available_app_roles": ["admin", "editor", "member"], three permissions, and a new updated_at.
// The Role role_timetrack_editor was seeded with no permissions.
There are two kinds of caller, and they can change different fields:
| Field | Owner | applications.manage |
|---|---|---|
name, description, icon_url, launch_url, redirect_uris, support_url, email_from_name, settings_schema, permissions, available_app_roles, default_app_role |
✓ | ✓ |
visibility, review_status, review_notes, owner_user_id/owner_organization_id |
— (403 moderation_field_forbidden) |
✓ |
slug, tenant_id, id, created_at |
— | — (422 read_only_field) |
“Owner” means the owner_user_id User, or any org_admin of owner_organization_id. Ordinary members of the owning Organization can read the Application but not change it.
- Removing a name from
available_app_rolesthat still has a Role with assignments returns409 app_role_in_use. If the Role has no assignments, it’s deleted along with the name. - Removing a
permissionsentry still referenced by any Role returns409 permission_in_use(details.role_ids). - Adding a name to
available_app_rolesseeds the matching empty Role, same as on create. - Changing ownership is a platform action. It moves the Application to the new owner’s Tenant, which is a support-run data migration, so it’s only allowed when both Tenants are on the
sharedtier. Otherwise it returns409 ownership_change_requires_migration. - Writes
application.updated. Changes toreview_statuswriteapplication.review_status_changed.
Reviewing a submission
// Request — reject
{ "review_status": "rejected", "review_notes": "launch_url does not resolve; resubmit once it's live." }
// Request — suspend an already-launched app
{ "review_status": "suspended", "review_notes": "Repeated webhook signature failures suggest a compromised signing secret; paused pending developer confirmation." }
Allowed review_status transitions:
| From → to | Who | Notes |
|---|---|---|
pending_review → approved |
applications.manage |
review_notes optional. |
pending_review → rejected |
applications.manage |
review_notes required. |
approved → suspended |
applications.manage |
review_notes required. Blocks new grants and new app-token issuance; existing Entitlements and open sessions are untouched. |
suspended → approved |
applications.manage |
Reinstatement. |
rejected → pending_review |
automatic | The owner’s next PATCH to any configuration field resubmits it. There’s no separate resubmit endpoint. |
Any other transition returns 409 invalid_review_transition. A transition to rejected or suspended without review_notes returns 422 review_notes_required. Rejecting or suspending does not touch existing Entitlements. See Domain Model → The review lifecycle for why.
Errors specific to this resource
| Code | Status | When |
|---|---|---|
slug_taken |
409 | slug collides with an existing Application on create. |
application_not_found |
404 | {id} doesn’t resolve, or isn’t visible to the caller. |
app_role_in_use |
409 | PATCH would remove a role name whose Role still has assignments. |
permission_in_use |
409 | PATCH would remove a permission still referenced by a Role. |
invalid_settings_schema |
422 | The schema breaks a settings_schema rule. |
reserved_settings_key |
422 | The schema declares locale, timezone, theme, or notifications. |
moderation_field_forbidden |
403 | A non-admin owner’s PATCH touches a moderation field. |
review_notes_required |
422 | Transition to rejected/suspended without review_notes. |
invalid_review_transition |
409 | A review_status change not in the table above. |
ownership_change_requires_migration |
409 | An ownership change across non-shared Tenants. |
plan_limit_reached |
409 | POST would exceed the owner Tenant’s Applications cap, or POST/PATCH would exceed its AppRoles cap. |
subscription_required |
402 | POST on a restricted Tenant. |
read_only_field |
422 | PATCH touches id, slug, tenant_id, created_at, or settings_schema_stats. |
validation_failed |
422 | A field breaks a rule in the table above; see Validation errors. |
Validation errors
Every rule in The Application object table reports through details.fields, all failures at once:
// POST /v1/applications with a bad slug, an http launch_url, and too many role names
{
"error": {
"code": "validation_failed",
"message": "3 fields failed validation.",
"details": {
"fields": [
{ "field": "slug", "code": "invalid_format", "pattern": "^[a-z][a-z0-9-]{1,63}$" },
{ "field": "launch_url", "code": "invalid_format", "allowed": ["https"] },
{ "field": "available_app_roles", "code": "too_long", "max": 20 }
]
}
}
}
A reserved slug is { "field": "slug", "code": "unknown_value", "allowed": "anything but platform" }, and a taken one is 409 slug_taken, not a field error, because it’s about existing state.
Owner onboarding, end to end
What a paying customer does after staff have created their Application (concierge onboarding), all with their own User token and no platform permission:
- Find your Tenant:
GET /v1/tenantsreturns the one Tenant you own, with itsplanandtier.GET /v1/applications?owned=truelists your Applications. - Configure the app:
PATCH /v1/applications/app_invoicerwith yourredirect_uris,settings_schema,permissions, andavailable_app_roles. Then give each seeded Role its permissions withPATCH /v1/roles/role_invoicer_admin{"permissions": ["app.invoicer.view", "app.invoicer.send"]}. - Create your backend’s key:
POST /v1/api-keys{"name": "invoicer-backend", "scope": "app_invoicer", "permissions": ["entitlements.manage", "users.list"]}. Store thesecret; it’s shown once. Create a"mode": "test"key too for integration tests. - Subscribe to revocations:
POST /v1/webhooks{"scope": "app_invoicer", "url": "https://invoicer.example.com/hooks/substratal", "events": ["access.revoked", "role.removed"]}. Store thesigning_secret. This pair is the compliance minimum. - Grant access as your billing says: from your backend, with the app key,
POST /v1/users/{id}/entitlementsfor a personal purchase, orPOST /v1/organizations/{id}/entitlementsfor a team purchase. Both need anIdempotency-Key; key it on your own order id. - Check your bill:
GET /v1/tenants/{id}/usageshows where you are against your plan’s limits, andPOST /v1/tenants/{id}/billing/checkout-sessionsupgrades to Team when you need to.