Skip to main content

Refusal Reasons

Branch on the stable reason token, not on code, error or message: the 18 refusal reasons the Talonic API returns, what each means, and whether to retry.

A refusal that an integration has to branch on carries a `reason`: a stable, lower-case token beside the usual envelope fields. reason is the field to branch on. code only says which HTTP family the refusal belongs to (a 403 refusal reads INSUFFICIENT_PERMISSIONS whether the key lacks a scope, the app lacks a grant, or the person's role is too low). error is the HTTP status name on some refusals and a body discriminant on others, and message is prose written for people, which can change wording at any time. Two refusals with the same code and different reason need different handling, and only reason tells them apart.

reason is present only on refusals. When it is absent, there is no stable reason for that error: read message, use retryable to decide whether to retry, and log the request_id. Older clients may parse a prefix in message such as insufficient_grant: …; where a message carries one it equals reason, but not every refusal has a prefix (the Apps-ladder refusals are plain sentences), so new code should read reason. Structured hints for a refusal, such as required_scopes or required, arrive under details.

A refusal with a reason

{
  "statusCode": 403,
  "code": "INSUFFICIENT_PERMISSIONS",
  "reason": "insufficient_scope",
  "error": "insufficient_scope",
  "message": "This action requires the 'apps:decide' scope.",
  "retryable": false,
  "details": { "required_scopes": ["apps:decide"] },
  "request_id": "req_0468dcdadafb4488",
  "timestamp": "2026-10-06T09:12:44.120Z",
  "path": "/v1/apps/APP_ID/decision-tasks"
}

The 18 stable reasons

Refusal reasons

insufficient_grant403The app holds no approved grant covering this resource, or the route is not reachable by an app service key at all. Not retryable: ask the workspace owner to grant it.
install_pending_approval403A newer version of the app awaits owner approval and the install is paused. Poll GET /v1/apps/{id}/install/status.
install_not_ready403 / 409The install is not in force (403, no installed binding for this service key) or is parked on an unapproved version or has no bound OAuth client (409). Poll the install status.
endpoint_not_confirmed400The binding would give the install its first subscriptions, or confirm_subscriptions_endpoint does not equal the manifest's subscriptions_endpoint. An owner re-sends with the confirmation.
slot_unbound400 / 409A required requirement slot has no binding (400 at install or rebind, 409 when a run resolves an installed version). The owner binds the named aliases.
slot_unknown400The request named an alias the manifest declares no slot for. Fix the manifest or the request.
hosted_manifest_read_only409The manifest is fetched from the vendor's URL, so local edits are refused. Edit at the vendor host, then POST /v1/apps/{id}/refresh.
hosted_surface_fixed422A hosted app's surface URL comes from its manifest and moves only with a new approved version. Drop surface_url from the patch.
manifest_fetch_failed422The vendor's manifest URL could not be read; details.violations carries the reason. Fix it at the vendor host and retry.
schema_validation_failed422A manifest does not validate on publish or on a hosted fetch. details.violations lists { pointer, keyword, message } per failure.
refresh_failed422A hosted refresh did not happen; the app stays live on its approved version. details carries { changed, error }. Fix it at the vendor host.
insufficient_scope403The key or token lacks a required scope; required_scopes names the ones that admit. Mint a key or request a token with that scope.
unauthenticated403The Apps surface saw no authenticated principal. Check the Authorization header.
workspace_required403The Apps surface needs a concrete workspace; a master-view key cannot call it. Use a workspace key.
insufficient_tier403The caller's workspace role (or an OAuth token's role claim) ranks below the route's tier; required names the tier. A workspace owner must raise the role.
owner_required403Grant and OAuth-client management needs a human workspace owner. No machine caller reaches it.
insufficient_app_grant403The API key holds app grants, but none at the level this route needs; required names the tier. Ask the owner for a higher grant.
decide_grant_required403The decision-task protocol needs an explicit decide grant on the app (or an OAuth session holding apps:decide for a live Senior Member or above).

Retrying refusals

Most refusals are not retryable as-is: the same request will be refused again until a person changes something. Grant, scope, tier, and owner refusals (insufficient_grant, insufficient_scope, insufficient_tier, insufficient_app_grant, owner_required, decide_grant_required, workspace_required) need a workspace owner or a different credential, so surface them and stop. The install states (install_pending_approval, install_not_ready) resolve on their own once an owner acts, so poll GET /v1/apps/{id}/install/status with a backoff rather than retrying the refused call in a loop. manifest_fetch_failed and refresh_failed are worth retrying after the vendor host is fixed. The validation-style reasons (slot_unknown, schema_validation_failed, hosted_surface_fixed, endpoint_not_confirmed) need a corrected request.

The envelope's retryable flag still applies on top of reason: a false there means a blind retry cannot succeed. For errors without a reason (rate limits, transient server errors, validation errors), follow retryable and the Retry-After header as described in [Error Format](error-format) and [Rate Limits](rate-limits).

Branch on reason

const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
  const err = await res.json();
  switch (err.reason) {
    case 'install_pending_approval':
    case 'install_not_ready':
      return pollInstallStatus(appId);            // wait for the owner
    case 'insufficient_scope':
      return askForScopes(err.details?.required_scopes);
    case 'insufficient_grant':
    case 'insufficient_tier':
    case 'owner_required':
      return escalateToOwner(err.message, err.request_id);
    case undefined:
      if (err.retryable) return retryWithBackoff(); // no stable reason
      throw new Error(err.message);
    default:
      throw new Error(`${err.reason}: ${err.message}`);
  }
}
Treat an unknown reason as a refusal you cannot handle automatically: log it with the request_id and surface the message. The list above is stable, but new reasons can be added.

Frequently asked questions

Should I branch on code or reason?+
On reason. code only names the HTTP family, so several different refusals share INSUFFICIENT_PERMISSIONS; reason is the stable token that tells them apart. Messages are prose and can change.
Why is reason missing from an error?+
reason appears only on refusals that have a stable reason. When it is absent, read message, follow retryable and Retry-After, and keep the request_id for support.
Can I retry an insufficient_grant refusal?+
Not until a workspace owner grants the app access to the resource. Retrying the same call keeps returning the same refusal, so surface it and stop.
Where do required_scopes and required appear?+
Under details in the error envelope. insufficient_scope carries required_scopes; insufficient_tier and insufficient_app_grant carry required, naming the tier the route needs.