Applications

See Domain Model → Applications for the full field reference.

  1. Endpoints
  2. Who can see an Application
  3. The Application object
  4. GET /applications
  5. GET /applications/{id}
  6. POST /applications
  7. PATCH /applications/{id}
    1. Reviewing a submission
    2. Retiring an Application
  8. DELETE /applications/{id}
  9. Errors specific to this resource
    1. Validation errors
  10. Owner onboarding, end to end

Endpoints

Method Path Requires Purpose
GET /applications any authenticated caller List the catalog, filtered by visibility rules.
GET /applications/{id} any caller who can see it Fetch one Application.
POST /applications applications.manage Register a new Application. Platform-admin only through Phase 2.
PATCH /applications/{id} the owner (configuration fields) or applications.manage (anything) Update a catalog entry.
DELETE /applications/{id} applications.manage Delete a test-mode Application nothing references.

Who can see an Application

visibility decides who gets an Application from GET /applications and GET /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",
  "receives_platform_notifications": false,
  "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",
  "access_tier": "standard",
  "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.
receives_platform_notifications Boolean, default false, writable by the owner, except that only applications.manage can turn it on for a self_registered Application (403 moderation_field_forbidden). Whether this app’s devices also get pushes for platform alerts. The app’s push credentials are managed separately, under /applications/{id}/push-credentials.
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 /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.
access_tier free or standard (default). Which Pricing tier unlocks the Application. Only applications.manage sets it. Setting or changing it grants or ends the matching platform-managed Entitlements in the same request.
tenant_id Read-only. Resolved from the owner’s Tenant at creation, and the Tenant is created if needed.

GET /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",
      "access_tier": "standard",
      "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.
access_tier Anyone. ?access_tier=free lists the free catalog.
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.
include_inactive=true Anyone. Includes retired Applications, which are left out by default. ?review_status=retired lists only them.

GET /applications/{id}

Returns the full Application object above.

POST /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",
  "access_tier": "standard",
  "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",
  "access_tier": "standard",
  "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.

There are two kinds of caller:

  • applications.manage (staff) creates any Application, for any owner, approved unless it says otherwise. This is concierge onboarding: staff create a paying customer’s Application with the customer as owner, and the owner manages it from then on. self_registered is false.
  • Any other User with a verified email can submit their own Application for review (self-service registration):
    • It’s owned by the caller. Omit both owner fields, or pass your own owner_user_id, or pass owner_organization_id for an Organization you’re an active org_admin of. Anything else is 403 moderation_field_forbidden.
    • It starts in pending_review, public, and standard. Setting visibility, access_tier, review_status, review_notes, or turning on receives_platform_notifications is 403 moderation_field_forbidden.
    • self_registered is true. A self-registered Application is third-party, so it must use the hosted sign-in flow: POST /auth/app-tokens returns 403 embedded_login_not_allowed for it.
    • An unverified email is 403 email_not_verified. An API Key without applications.manage is 403 forbidden. Each User can submit 10 per day.
    • Until it’s approved it’s visible only to its owner and staff, it can’t be granted or launched (409 application_not_available), and it gets no platform-managed Entitlements. Staff work the queue with GET /applications?review_status=pending_review; see Reviewing a submission.

For either caller:

  • slug, name, and launch_url are required, and staff must also pass exactly one of owner_user_id/owner_organization_id. Everything else is optional.
  • Resolves the owner’s Tenant, creating one if needed. A new Tenant gets tier: shared.
  • Seeds the app’s Roles. For each name in available_app_roles, a Role role_<slug>_<name> is created with no permissions, and the owner then fills them in with PATCH /roles/{id}.
  • Grants the platform-managed Entitlements its access_tier calls for, once it’s approved: every User for a free Application, every covered subscriber for a standard one.
  • Writes application.created.

PATCH /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, access_tier, 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_roles that still has a Role with assignments returns 409 app_role_in_use. If the Role has no assignments, it’s deleted along with the name.
  • Removing a permissions entry still referenced by any Role returns 409 permission_in_use (details.role_ids).
  • Adding a name to available_app_roles seeds 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 shared tier. Otherwise it returns 409 ownership_change_requires_migration.
  • Writes application.updated. Changes to review_status write application.review_status_changed.
  • A retired Application takes no new credentials. Creating an API Key, webhook subscription, or push credential for it, or re-enabling one of its webhooks, returns 409 application_retired.

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.
approved → retired, suspended → retired applications.manage review_notes required. Terminal, and it cascades; see Retiring an Application.
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, including anything out of retired, and creating an Application already retired. A transition to rejected, suspended, or retired without review_notes returns 422 review_notes_required. Rejecting or suspending does not touch existing Entitlements. See Domain Model → The review lifecycle for why.

Retiring an Application

// Request
{ "review_status": "retired", "review_notes": "Shut down by its developer on 2026-10-31." }

Retiring cuts the Application off in the same transaction as the change, in test and live mode alike. So a live Application can only be retired with a live-mode credential (403 live_mode_required). It does the following:

  • Entitlements. Platform-managed ones (free, subscription) end (ends_at is now), as when an access tier changes. Every other live one is revoked with disabled_reason: application_retired, writing entitlement.revoked. Each User who loses access gets access.revoked with reason: application_retired.
  • API Keys scoped to it are revoked (api_key.revoked).
  • Webhook subscriptions scoped to it are disabled, so its own endpoints don’t receive these events; platform-scoped subscribers do.
  • Push credentials become disabled. Devices become inactive with inactive_reason: application_retired (device.deactivated).
  • App tokens can’t be issued for it (409 application_not_available), and nothing can be granted. Re-enabling or renewing one of its Entitlements is 409 application_retired.

It writes application.review_status_changed and application.retired, whose after.cut_off counts what was cut off. Roles, settings, audit history, and the slug stay. Destructive.

DELETE /applications/{id}

applications.manage only. Deletes a test-mode Application that nothing references, for cleaning up after integration testing. Returns 204, and writes application.deleted with the Application in before. Its own Roles go with it; its audit history stays, and its slug can be used again. Destructive.

  • A live Application can’t be deleted: 409 application_not_deletable. Retire it instead.
  • If anything still references it, in either mode, the result is 409 application_in_use, with details.references naming each kind that remains: api_keys, app_profiles, app_settings, devices, entitlements, notifications, push_credentials, refresh_tokens, role_assignments, signup_users, or webhooks.

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, or a self-service POST, touches a moderation field, names an owner other than the caller (or an Organization they’re not an org_admin of), or turns on receives_platform_notifications for a self-registered Application.
email_not_verified 403 A self-service POST from a User whose email isn’t verified.
review_notes_required 422 Transition to rejected/suspended/retired without review_notes.
invalid_review_transition 409 A review_status change not in the table above.
live_mode_required 403 A test-mode credential retires a live Application.
application_retired 409 A new API Key, webhook, or push credential, a webhook re-enable, or an Entitlement re-enable or renewal, for a retired Application.
application_not_deletable 409 DELETE on a live Application.
application_in_use 409 DELETE on a test-mode Application something still references (details.references).
ownership_change_requires_migration 409 An ownership change across non-shared Tenants.
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 /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 an Application owner does after staff have created their Application (concierge onboarding), all with their own User token and no platform permission:

  1. Find your Tenant: GET /tenants returns the one Tenant you own, with its tier. GET /applications?owned=true lists your Applications.
  2. Configure the app: PATCH /applications/app_invoicer with your redirect_uris, settings_schema, permissions, and available_app_roles. Then give each seeded Role its permissions with PATCH /roles/role_invoicer_admin {"permissions": ["app.invoicer.view", "app.invoicer.send"]}.
  3. Create your backend’s key: POST /api-keys {"name": "invoicer-backend", "scope": "app_invoicer", "permissions": ["entitlements.manage", "users.list"]}. Store the secret; it’s shown once. Create a "mode": "test" key too for integration tests.
  4. Subscribe to revocations: POST /webhooks {"scope": "app_invoicer", "url": "https://invoicer.example.com/hooks/substratal", "events": ["access.revoked", "role.removed"]}. Store the signing_secret. This pair is the compliance minimum.
  5. Grant access as your billing says: from your backend, with the app key, POST /users/{id}/entitlements for a personal purchase, or POST /organizations/{id}/entitlements for a team purchase. Both need an Idempotency-Key; key it on your own order id.