Skip to content

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_parametersvalue is 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_parametersvalue is an object keyed by parameter name, with the rule that triggers the requirement (e.g. "required when type is lead").

  • conditionally_required_body_fields — same, for body fields.

  • rate_limit_error — present on a 429 from the provider. Pair with the Retry-After header.

  • remote_error — present whenever truto_is_remote_error is true. The hint is intentionally short — read raw_response and message for the actual content.

  • forbidden_error — present on a 403 from the provider. Not every 403 is a scope problem, so value states what Truto worked out and, where it could not work anything out, why not.

    value.refusal_basis is 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_basis Will a reconnect help? What it means
    scopes_missing Yes The scopes in value.missing_scopes are absent from the grant. Drive a re-consent flow.
    scopes_satisfied No Every declared scope is granted, so the refusal has another cause. Read what the provider said.
    non_scope_requirement No 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_concept No This connection authenticates with an API key or OAuth 1.0a, which have no scopes — there is nothing to grant.
    not_determined Maybe No comparison ran and Truto cannot name a basis. Read description for which reason applied, and raw_response for the provider's own account.

    not_determined covers 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 API description still names which one applied.

    Do not rely on description to 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 no required_permissions — because having no recorded requirement is what defines it. The other two always carry the requirement, since a comparison was attempted against one. So not_determined with required_permissions means the provider's grant was unreadable or undisclosed, and not_determined without 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_scopes appears only for scopes_missing and scopes_satisfied, and only ever means "these declared scopes are not in the grant". Do not default an absent missing_scopes to [] — absent means no comparison happened, which is not the same statement as "nothing is missing". value.missing_any_of_scopes is 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 counterpart value.required_any_of_permissions, and value.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 only oauth_scope is fixed by reconnecting. All three are withheld for no_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_response is the error body verbatim, and message is the human text pulled out of it. raw_response is 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_response yourself, or, if you want one integration's code lifted into a stable field, do it per integration with an error_expression or error_mapping written against that provider's actual shape.

    Note that message is 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_error on the integrated account keeps Truto's own reading — refusal_basis, description, missing_scopes, missing_any_of_scopes, required_permissions, required_any_of_permissions and permission_vocabulary — plus one capped copy of the provider's body as raw_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 … truncated marker, and a truncated one is a byte prefix of a serialized body, so JSON.parse on it throws. What the row does not keep is message, so re-run the call to see that.

    If you read missing_scopes today, note which states no longer carry it. It appears only for scopes_missing and scopes_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 carries required_permissions with permission_vocabulary set to api_key_permission or role_label instead. Read those two for the names and the remedy — only oauth_scope is fixed by reconnecting — and refusal_basis for 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_vocabulary at config.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, null and [] all fall back to the derived answer rather than describing a 403 with undefined — 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_scope is 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 scopes and scopes_any_of, on the environment integration's override.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_scopes in the first place. In the public catalogue that is haloitsm and jamf, both oauth2_client_credentials, both declaring console role names like Read - Users. They previously emitted those names under missing_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 from permission_vocabulary rather than from a name list.

    One more change to the durable record: missing_scopes is 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.

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 CreatedPOST succeeded and a new resource was created.
  • 204 No Content — request succeeded with no body. Common for DELETE.

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; check truto_error_insight.
  • 401 Unauthorized — without truto_is_remote_error, your API token is missing, expired, or wrong. With truto_is_remote_error: true, the provider rejected the connection's credentials — Truto flips the integrated account to needs_reauth, sets last_error on it, and fires the integrated_account:authentication_error webhook. 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 read raw_response and message first — the provider is the system that refused, so its own words are the authoritative account — then truto_error_insight.forbidden_error.value.refusal_basis for Truto's reading. Only refusal_basis: "scopes_missing" means a reconnect with the right scopes will fix it. non_scope_requirement points 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 on POST / PATCH / DELETE calls because they're read-only.
  • 409 Conflict — the provider rejected a create or update because of a uniqueness constraint. Always carries truto_is_remote_error: true; check raw_response for 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. Contact support@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 carry truto_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 with truto_is_remote_error: true is the provider failing, not Truto. Retry policy should be the same as for any flaky upstream — exponential backoff, capped attempts.