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. Icon uploads
  10. Errors specific to this resource
    1. Validation errors
  11. 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.
POST /applications/{id}/icon/uploads the owner or applications.manage Start an icon upload.
POST /applications/{id}/icon/uploads/{uploadId}/complete the owner or applications.manage Check the uploaded image and make it the icon.
DELETE /applications/{id}/icon the owner or applications.manage Remove the icon.

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,
  "review_requested_at": 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 Who gets in without a grant: free (every User), standard (Standard subscribers; first_party Applications only), or restricted (only people the developer grants). A first-party Application defaults to standard and only applications.manage changes it. Any other, self-registered or staff-onboarded, defaults to restricted, and its owner sets free or restricted; standard is 422 access_tier_reserved. Setting or changing it grants or ends the matching platform-managed Entitlements in the same request.
first_party true for Substratal’s own Applications. Only applications.manage sets it, and only a first-party Application can be standard. Defaults to false, including for customers’ Applications that staff create.
tenant_id Read-only. Resolved from the owner’s Tenant at creation, and the Tenant is created if needed.
review_requested_at Read-only. When the Application last entered pending_review: set when it’s created in review, and again each time the owner’s edit resubmits it after a rejection. Every other change keeps it, and it’s null until the Application is first in review.

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",
      "review_requested_at": null,
      "owner_user_id": null,
      "owner_organization_id": "org_01JAFZ8Y7X6W5V4V3T2S1R0Q9P",
      "holder_count": 42
    }
  ],
  "page": { "next_cursor": null, "has_more": false }
}

List responses return a trimmed view. They leave out settings_schema, permissions, redirect_uris, and the other configuration fields, so fetch the single resource for the full object. Each row does carry its owner (owner_user_id or owner_organization_id, the other null) and holder_count: the Application’s active Entitlements, where a User grant and an Organization grant count once each, in live and test mode alike.

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, sorted by review_requested_at, so the submission that has waited longest comes 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.

Order. Rows are ordered by created_at, with ties broken by id, newest first by default. The review queue (?review_status=pending_review with applications.manage) defaults to oldest-waiting first, by review_requested_at (falling back to created_at), so a resubmission waits behind newer first submissions. sort sets the order explicitly by created_at, for anyone, in the queue too:

sort Order
created_at Oldest first. ?review_status=pending_review&sort=created_at is the queue by original submission date instead of by time waiting.
-created_at Newest first.

Any other value is 422 validation_failed (enum_mismatch). sort is part of the filters a cursor is bound to, so a cursor from one order is 400 invalid_cursor under the other, and pages never overlap or skip rows.

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,
  "review_requested_at": 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 and public. access_tier is free or restricted (the default), your choice; standard is 422 access_tier_reserved. Setting visibility, 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, and nobody for a restricted one, whose access comes only from the developer’s own grants.
  • 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 ✓ ✓
access_tier Not first-party: free or restricted, while approved or pending_review (409 application_not_available otherwise; at most 10 changes a day). First-party: — (403 moderation_field_forbidden) ✓ (standard only when first_party)
first_party — (403 moderation_field_forbidden) ✓
visibility, review_status, review_notes, owner_user_id/owner_organization_id — (403 moderation_field_forbidden) ✓
slug, tenant_id, id, created_at, review_requested_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, and sets review_requested_at to now. 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.

Icon uploads

The platform hosts icons at https://assets.substratalapps.com, the same way it hosts avatars: start an upload, send the file straight to storage with the presigned POST it returns, then complete it. Who may: the owner (owner_user_id, or an active org_admin of owner_organization_id) or applications.manage, as for configuration fields in PATCH; anyone else gets 403 forbidden. As with any write, a suspended Tenant is 403 tenant_suspended, and one mid-migration 503.

  • POST /applications/{id}/icon/uploads takes { "content_type", "content_length" } and returns 201 with an ImageUpload, exactly as for avatars, except that the cap is 1 MB (1,048,576 bytes). Limited to 10 per caller per hour, shared with avatar uploads.
  • POST /applications/{id}/icon/uploads/{uploadId}/complete runs the avatar checks, and one more: an icon must be square (422 invalid_image, reason: not_square). A file that passes is stored at https://assets.substratalapps.com/apps/<application_id>/icon-<hash>.<ext> and becomes icon_url; the previous hosted icon is deleted. Returns 200 with the updated Application.
  • DELETE /applications/{id}/icon sets icon_url to null and deletes the hosted image, if it is one; an external URL is just cleared. 204. Destructive.

Each change writes application.icon_changed, with icon_url in before and after. icon_url still accepts any https URL through PATCH, for compatibility, but first-party clients only display images from assets.substratalapps.com. Changing it away from a hosted icon deletes that image, and so does deleting a test-mode Application. A test-mode Application’s icon is kept under a separate test/ prefix. See NFR → Image uploads.

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).
invalid_image 422 An uploaded icon failed a check (details.reason; see Avatar uploads, plus not_square).
upload_not_found 404 Completing an icon upload that was never sent, was already completed, or has expired.
service_unavailable 503 Image uploads aren’t configured in this environment.
ownership_change_requires_migration 409 An ownership change across non-shared Tenants.
read_only_field 422 PATCH touches id, slug, tenant_id, created_at, review_requested_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.