Read an error and act on it
The envelope every failure arrives in, and what to do about each code.
A successful response carries data. A failed one carries error.
{
"error": {
"code": "NOT_FOUND",
"message": "Sucursal no encontrada",
"requestId": "bf44aad4-6554-45ea-95f5-bc215f11d373"
}
}| Field | What it is |
|---|---|
code | The contract. It is the only thing to branch on. |
message | For a person. Its wording can change without notice. |
requestId | Your call's identifier in our logs. Record it. |
details | Optional. What exactly failed, when that can be said. |
field_errors | Optional. The same as details, indexed by field. |
The code is uppercase with underscores and comes from a closed list. You have it in full in Error codes.
The message arrives in Spanish when it names something from the domain: "Sucursal no encontrada", "Cita no encontrada". It arrives in English when it describes a problem with the call: "Missing required scope: webhooks:write". That's the API's behaviour, and it doesn't follow the language you read these docs in. Don't show it to an end user as it is, and don't parse it.
The ones you'll see
401 UNAUTHORIZED: the credential doesn't work
One code, three messages.
With no Authorization header:
{ "error": { "code": "UNAUTHORIZED", "message": "Missing bearer token", "requestId": "fb978d51-e751-4ab6-903d-22ba53592320" } }With a key that doesn't exist, or that has already been revoked:
{ "error": { "code": "UNAUTHORIZED", "message": "Invalid API key", "requestId": "a1e8a7a9-de68-4d74-9c35-20158654a987" } }With a live key whose expires_at has passed:
{ "error": { "code": "UNAUTHORIZED", "message": "API key expired", "requestId": "89f197a7-8787-4620-bd8f-981bf2a939e9" } }The first is almost always a deployment that lost its environment variable. The second doesn't tell a key that never existed apart from a revoked one. The third is avoided by rotating the key before its expires_at.
Retrying fixes none of the three.
403 FORBIDDEN: a scope is missing
{ "error": { "code": "FORBIDDEN", "message": "Missing required scope: marketplace:read", "requestId": "2c5645c2-d902-4e6f-ad1c-8d924b063ba1" } }The message names the missing permission. Mint a new key that includes it: a key's scopes can't be edited. It's in Authentication.
403 CONNECTED_APP_SENSITIVE_DATA: a connected app asked for sensitive data
{ "error": { "code": "CONNECTED_APP_SENSITIVE_DATA", "message": "Esta operación entrega datos personales sensibles (…). Una aplicación conectada no puede leerlos: úsala con una API key o un token personal del workspace, emitido para este fin. …", "requestId": "7d0b3c2e-5a41-4f7e-9c11-2b8e6f0a4d93" } }The credential belongs to a connected app, a client authorised through OAuth. The code comes back in two cases. One, the operation is marked x-vitrina-sensitive. Two, the response isn't JSON: an export, a file, a stream. That second case applies on a workspace that shows people to connected apps as pseudonyms, and what doesn't go through that filter doesn't leave.
Asking for more scopes won't fix it: what's closed is that kind of credential. If your integration really needs the data, use a workspace API key or personal token. It's in Personal and health data.
404 NOT_FOUND: it doesn't exist, or it isn't yours
{ "error": { "code": "NOT_FOUND", "message": "Sucursal no encontrada", "requestId": "75a3d88b-8703-42a2-9c40-127fec0dfcc3" } }A resource from another workspace answers exactly like one that doesn't exist.
400 VALIDATION_ERROR: the call is written wrong
It's the only one that tells you where:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"body": [{
"path": "comuna_code",
"message": "comuna_code must be the official 5-digit CUT code (13114 = Las Condes), never a comuna name and never a portal id",
"code": "invalid_string"
}]
},
"field_errors": {
"comuna_code": "comuna_code must be the official 5-digit CUT code (13114 = Las Condes), never a comuna name and never a portal id"
},
"requestId": "4c1e5e23-ae89-4e57-a711-5e962e8f4a8b"
}
}details is grouped by where the problem was: body, query or params. A limit out of range arrives under query; an id that isn't a uuid, under params.
field_errors is the same content shaped as { field: message }. It's there so a form can paint the error under the right input without walking details. It only appears when the failure can be pinned on a field.
Never retry a 400 without changing the call.
409 IDEMPOTENCY_KEY_CONFLICT: the same header with a different body
Every published POST accepts the Idempotency-Key header, with nothing to turn on per operation. Published means an estable or beta tier; internal operations are out. Only POST, because it's the one verb that creates something new every time it repeats.
Repeating the same value with the same body returns the original response and adds X-Idempotent-Replay: 1. That's what makes it safe to retry a POST whose response never reached you.
Repeating it with a different body is another matter:
{
"error": {
"code": "IDEMPOTENCY_KEY_CONFLICT",
"message": "Idempotency-Key '8592D625-BC6E-4A4B-90B0-C94CCF623D71' was previously used with a different request body",
"requestId": "9973642b-1646-4a4f-9c2e-fef172ac73c4"
}
}That isn't a retry: it's a new operation reusing an old value. Generate one Idempotency-Key per operation.
422 OUTBOUND_BLOCKED and 409 OUTBOUND_WARNING: the outbound policy
The two verdicts a send made with a credential can get. They carry two fields no other error carries, reasons and hint, and they ask the opposite of you. The 422 can't be retried. The 409 can, by resending the same call with acknowledge.
{
"error": {
"reasons": ["template_missing", "window_closed"],
"hint": "La ventana de 24 horas está cerrada: usa una plantilla aprobada (POST /conversations/{id}/templates).",
"code": "OUTBOUND_BLOCKED",
"message": "This message was not sent: the outbound policy blocks it (template_missing, window_closed). A block cannot be acknowledged — see error.hint for what to do instead.",
"requestId": "03c42c0e-0769-4659-b500-6464a85c3a73"
}
}What each reason means, and how a warning is acknowledged, is in Send messages.
429 RATE_LIMITED: you went past the ceiling
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
Retry-After: 1{ "error": { "code": "RATE_LIMITED", "message": "Too many requests", "requestId": "c712a422-32d0-4ea2-bba4-e7e60067560a" } }It's the only 4xx that waiting fixes. Retry-After is in seconds and the counter that rejected you computes it, so wait it out instead of inventing a backoff. The detail is in Authentication.
500 INTERNAL_ERROR: it broke on our side
{ "error": { "code": "INTERNAL_ERROR", "message": "Internal server error", "requestId": "3c5f4e5e-34c1-4f97-8caa-98632613c895" } }It never carries details: a 5xx doesn't describe its internals. Retry with backoff. If it persists, write to us with the requestId; that's what finds the exact call.
Trap
One HTTP status covers errors that are fixed differently
409 is an idempotency conflict (IDEMPOTENCY_KEY_CONFLICT: generate another
value) and also a uniqueness one (UNIQUE_CONFLICT: change the data). It
happens the other way round too: VALIDATION_ERROR arrives as 400 when the
call failed validation and as 422 when it parsed fine but pointed at
something unusable.
The status tells you the family; the code tells you what to do.
Every value error.code can take, with its HTTP status, is in Error codes.