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
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}`);
}
}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.