Errors
Every Truto error comes back with the right HTTP status, a JSON body shaped the same way regardless of route, and — for Unified and Proxy APIs — extra fields that tell you whether the failure happened at Truto's edge or inside the provider you're calling.
Response shape
Successful responses are documented per endpoint. Error responses always look like this:
{
"statusCode": 400,
"error": "Bad Request",
"message": "name is required"
}Three fields are guaranteed:
statusCode— the same number as the HTTP status. Mirrors the response status line.error— the canonical name for that status, e.g.Bad Request,Unauthorized,Not Found,Too Many Requests,Internal Server Error.message— a human-readable description. For provider-originated errors this is extracted from the provider's body (see below).
Errors raised by Truto's own validation, auth, and routing layers (admin endpoints, Unified API parameter checks, missing integrated_account_id, malformed request bodies, etc.) stop here.
Errors from the underlying provider — anything you hit through /unified/* or /proxy/* — carry several extra fields.
Distinguishing Truto errors from provider errors
When a Unified or Proxy API call fails because the underlying provider returned a non-2xx, the response gains:
truto_is_remote_error: true— set on every error that originated outside Truto.raw_response— the provider's response body, parsed as JSON if possible, otherwise the raw text.- All response headers from the provider, normalized and forwarded back (rate-limit headers,
Retry-After, etc. — see Rate limits).
{
"statusCode": 422,
"error": "Unprocessable Entity",
"message": "Email is invalid",
"truto_is_remote_error": true,
"raw_response": {
"errors": [
{
"field": "email",
"code": "invalid",
"message": "Email is invalid"
}
]
}
}The message is normalized by walking the provider body for the usual error fields (message, msg, errorMessage, description, detail, summary, error_description, and a long list of variants). When that lookup finds nothing, message is empty and you should fall back to raw_response.
If the field is missing, the error came from Truto itself — almost always a 4xx for input you sent (missing integrated_account_id, malformed body, expired token), or a 5xx that means we couldn't reach the provider at all.
Insights — truto_error_insight
Unified API errors come with a truto_error_insight block that tells you, in machine-readable form, what to change. Each key is independent; you may see one, several, or none.
{
"statusCode": 400,
"error": "Bad Request",
"message": "name is required",
"truto_error_insight": {
"missing_required_body_fields": {
"description": "These body fields are required and are missing in the request",
"value": ["name"]
}
}
}The keys you can see:
-
missing_required_query_parameters—valueis an array of query parameter names you didn't send but the unified model requires. -
missing_required_body_fields— same, for body fields. -
conditionally_required_query_parameters—valueis an object keyed by parameter name, with the rule that triggers the requirement (e.g. "required whentypeislead"). -
conditionally_required_body_fields— same, for body fields. -
rate_limit_error— present on a 429 from the provider. Pair with theRetry-Afterheader. -
remote_error— present whenevertruto_is_remote_erroristrue. The hint is intentionally short — readraw_responseandmessagefor the actual content. -
forbidden_error— present on a 403 from the provider. Not every 403 is a scope problem, sovaluestates what Truto worked out and, where it could not work anything out, why not.value.refusal_basisis always present and states what Truto can say the refusal rests on. There are five values, and each one implies a different remedy — which is the point of reading it rather than reading a list of scopes:refusal_basisWill a reconnect help? What it means scopes_missingYes The scopes in value.missing_scopesare absent from the grant. Drive a re-consent flow.scopes_satisfiedNo Every declared scope is granted, so the refusal has another cause. Read what the provider said. non_scope_requirementNo The requirement is a role or an API-key permission, named in value.required_permissions, and granted inside the provider rather than by reconnecting.no_scope_conceptNo This connection authenticates with an API key or OAuth 1.0a, which have no scopes — there is nothing to grant. not_determinedMaybe No comparison ran and Truto cannot name a basis. Read descriptionfor which reason applied, andraw_responsefor the provider's own account.not_determinedcovers three situations that differ only in why Truto has no answer — no requirement recorded for this resource and method, the provider never echoed which scopes it granted, or the granted string could not be split with confidence. They are one value because the remedy is identical in all three, and on this APIdescriptionstill names which one applied.Do not rely on
descriptionto recover the reason on every surface, though. The end-user connect widget is deliberately sent no Truto prose, so it reads the reason structurally instead: the first situation is Truto's own missing catalogue entry, and it is the only one of the three that carries norequired_permissions— because having no recorded requirement is what defines it. The other two always carry the requirement, since a comparison was attempted against one. Sonot_determinedwithrequired_permissionsmeans the provider's grant was unreadable or undisclosed, andnot_determinedwithout it means Truto has nothing recorded for that resource and method.Treat this as an open set. The read path deliberately accepts a value it does not recognise, so a newly shipped state cannot break a client. Generate a permissive type and keep a default branch.
value.missing_scopesappears only forscopes_missingandscopes_satisfied, and only ever means "these declared scopes are not in the grant". Do not default an absentmissing_scopesto[]— absent means no comparison happened, which is not the same statement as "nothing is missing".value.missing_any_of_scopesis separate, for requirements where holding any one alternative is enough; folding the two together would tell a reader to grant every alternative.value.required_permissions, its any-of counterpartvalue.required_any_of_permissions, andvalue.permission_vocabulary(oauth_scope/api_key_permission/role_label) state what the call needs whether or not a comparison was possible — the vocabulary decides the remedy, and onlyoauth_scopeis fixed by reconnecting. All three are withheld forno_scope_concept, where the declared permissions belong to an authentication method this connection does not use, so naming them would send you looking for a setting that does not exist.Everything above is Truto's own reading, inferred from integration configuration. What the provider said travels beside it on the 403 response itself, and it is the authoritative account:
raw_responseis the error body verbatim, andmessageis the human text pulled out of it.raw_responseis on every remote 403.Truto deliberately does not summarise that body — there is no field carrying "the provider's error code" or "the provider's documentation link". Which key in an arbitrary provider's error shape is the important one has no correct general answer, and a confidently wrong pick placed above a correct body is worse than no pick at all. Read
raw_responseyourself, or, if you want one integration's code lifted into a stable field, do it per integration with anerror_expressionorerror_mappingwritten against that provider's actual shape.Note that
messageis unbounded: the extractor's last candidate is the payload itself when the payload is a string, so for a plain-text or HTML body it is the whole body. Cap it before you display it.Both of those are on the response only. The durable
last_forbidden_erroron the integrated account keeps Truto's own reading —refusal_basis,description,missing_scopes,missing_any_of_scopes,required_permissions,required_any_of_permissionsandpermission_vocabulary— plus one capped copy of the provider's body asraw_response_excerpt, so a connection page opened days later still shows what the provider said and not only what Truto inferred. That excerpt is capped at 2,048 bytes including its trailing… truncatedmarker, and a truncated one is a byte prefix of a serialized body, soJSON.parseon it throws. What the row does not keep ismessage, so re-run the call to see that.If you read
missing_scopestoday, note which states no longer carry it. It appears only forscopes_missingandscopes_satisfied.The condition, rather than a list of names that goes stale the moment an integration is added: any connection whose declared requirement is not an OAuth scope now reports
non_scope_requirement, runs no scope diff, and carriesrequired_permissionswithpermission_vocabularyset toapi_key_permissionorrole_labelinstead. Read those two for the names and the remedy — onlyoauth_scopeis fixed by reconnecting — andrefusal_basisfor whether a comparison happened at all.Which vocabulary an integration uses is worked out from its own configuration, so every integration is classified without anyone labelling it. An integration author can also declare it outright with
permission_vocabularyatconfig.resources.<resource>.<method>, which wins where it is set; that exists to correct the rare integration the derivation reads wrongly, and you should not need to care which case you are looking at. Three things about the override are worth knowing if you set one:- An unrecognised value is ignored, not rejected at read time.
"banana",42,nulland[]all fall back to the derived answer rather than describing a 403 withundefined— so a typo shows you a plausible result and no signal that it was ignored. On a write through the API the closed set is enforced, so the typo is caught there. - A declared
oauth_scopeis ignored when that same method's permission list contains whitespace. RFC 6749 makes the space the delimiter between scope tokens, so a token containing one cannot be a scope; the declaration and the list contradict each other, and the list wins. This is the only case where a declaration does not take effect. - It can be overridden per environment, alongside
scopesandscopes_any_of, on the environment integration'soverride.resources.<resource>.<method>— so one environment can be corrected without touching the base integration.
Of those, the ones that actually lose a field they used to send are the OAuth-authenticating ones, because an API-key connection never had a scope grant to diff and so never carried
missing_scopesin the first place. In the public catalogue that ishaloitsmandjamf, bothoauth2_client_credentials, both declaring console role names likeRead - Users. They previously emitted those names undermissing_scopes, which told a reader to reconnect and grant a permission reconnecting cannot grant. Your own environment's set depends on which integrations it has enabled — derive it frompermission_vocabularyrather than from a name list.One more change to the durable record:
missing_scopesis now stored in the order the integration declares it, matching the live response, where it was previously sorted. That is the only list with a previous state — the others beside it are new fields in this change and were never stored sorted. Nothing depends on the order; it is noted because a row written before this differs from one written after. - An unrecognised value is ignored, not rejected at read time.
Proxy API errors carry truto_is_remote_error, raw_response, forwarded headers, and — on 403s, including one an integration's own error_expression maps to 403 — a truto_error_insight.forbidden_error. The Proxy API has no unified schema to compare your request against, so the missing-parameter, rate-limit, and remote-error insights aren't added.
{
"statusCode": 403,
"error": "Forbidden",
"message": "Insufficient permissions",
"truto_is_remote_error": true,
"raw_response": {
"error": "insufficient_scope",
"error_description": "Token does not have crm.objects.contacts.write scope"
},
"truto_error_insight": {
"forbidden_error": {
"description": "Access forbidden. This connection does not hold every scope this resource and method requires — reconnect and grant the scopes listed alongside this error.",
"resource": "contacts",
"method": "create",
"value": {
"refusal_basis": "scopes_missing",
"missing_scopes": ["crm.objects.contacts.write"],
"required_permissions": ["crm.objects.contacts.write"],
"permission_vocabulary": "oauth_scope"
}
}
}
}Status codes
The numbers below are what you'll actually see; the meaning given is what they mean inside Truto.
2xx
200 OK— request succeeded. Body contains the requested resource.201 Created—POSTsucceeded and a new resource was created.204 No Content— request succeeded with no body. Common forDELETE.
4xx
400 Bad Request— Truto rejected the request before it left our edge. On Unified APIs this is also where missing-required-field errors land; checktruto_error_insight.401 Unauthorized— withouttruto_is_remote_error, your API token is missing, expired, or wrong. Withtruto_is_remote_error: true, the provider rejected the connection's credentials — Truto flips the integrated account toneeds_reauth, setslast_erroron it, and fires theintegrated_account:authentication_errorwebhook. The connection will keep returning 401 until the end user reconnects.403 Forbidden— the caller is authenticated but not allowed. On Unified and Proxy APIs readraw_responseandmessagefirst — the provider is the system that refused, so its own words are the authoritative account — thentruto_error_insight.forbidden_error.value.refusal_basisfor Truto's reading. Onlyrefusal_basis: "scopes_missing"means a reconnect with the right scopes will fix it.non_scope_requirementpoints at a role or API-key permission granted inside the provider, and the rest are licensing, plan, admin-permission or configuration problems that a re-consent flow will not touch.404 Not Found— the resource doesn't exist, or the integrated account / environment isn't visible to your token.405 Method Not Allowed— the HTTP method isn't supported on that route. Sandbox accounts return this onPOST/PATCH/DELETEcalls because they're read-only.409 Conflict— the provider rejected a create or update because of a uniqueness constraint. Always carriestruto_is_remote_error: true; checkraw_responsefor the specific field.422 Unprocessable Entity— payload is well-formed but the provider rejected it (validation error inside the provider).429 Too Many Requests— see Rate limits below.503 Service Unavailable— the integrated account has been blocked. Contactsupport@truto.one.
5xx
500 Internal Server Error— Truto failed to process the request. Retry with backoff; if it persists, capture the response and contact support.502 Bad Gateway/504 Gateway Timeout— Truto reached the provider but the provider didn't respond cleanly. Safe to retry.
Rate limits
Truto enforces two rate-limit tiers in front of every request. Both return a 429 with Retry-After: 10.
| Scope | Limit | Triggered by |
|---|---|---|
| Per API token | 50 requests / 1s | The bearer token in Authorization |
| Per integrated account | 50 requests / 10s | The integrated_account_id query parameter (Unified and Proxy APIs only) |
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Too many requests. You can make 50 requests every 10 seconds per integrated account."
}When the underlying provider rate-limits you, the response is a 429 with truto_is_remote_error: true and the provider's Retry-After (or our normalized equivalent) in the headers. On Unified API routes you'll also get truto_error_insight.rate_limit_error. See Rate limits for the full set of headers Truto normalizes.
Retry strategy
- Retry on: 429 (after
Retry-After), 502, 503 (only if you didn't trigger it via a blocked account), 504, and 5xx that don't carrytruto_is_remote_error. - Don't retry on: 400, 401, 403, 404, 409, 422 — fix the request, the token, or the connection first.
- Watch
truto_is_remote_error: a 5xx withtruto_is_remote_error: trueis the provider failing, not Truto. Retry policy should be the same as for any flaky upstream — exponential backoff, capped attempts.