Documentation
Errors
Every code the API can return, what it means and which ones are worth retrying.
An error uses the same envelope as a success, with status: "error". Branch on message_code, not on message.
{
"status": "error",
"message": "Ese chat ya tiene un turno de la API en curso.",
"message_code": "api_v1_chat_busy",
"data": { "message_id": 90212 },
"meta": { "request_id": "9f1c0a3e-7b2d-4f18-9a55-2c6e0d1b4a77" }
}Outages: error_details
When the cause is that a dependency is unavailable —not your mistake nor a business rule— the response adds meta.error_details. It is additive: the envelope root keeps the same five keys.
{
"status": "error",
"message": "No se pudo confirmar si la acción se aplicó. Consultá el estado del turno antes de reintentar.",
"message_code": "api_v1_action_result_unknown",
"data": [],
"meta": {
"request_id": "9f1c0a3e-7b2d-4f18-9a55-2c6e0d1b4a77",
"error_details": {
"schema_version": 1,
"service": "core_ai",
"service_code": "NC-SVC-01",
"dependency": "core_control",
"operation": "chat.action.resolve",
"upstream_code": null,
"failure_kind": "read_timeout",
"operation_outcome": "unknown",
"retryable": false,
"retry_action": "check_status",
"request_id": "9f1c0a3e-7b2d-4f18-9a55-2c6e0d1b4a77"
}
}
}| Field | What it says |
|---|---|
service_code / service | The component verified as down, from a stable catalog (NC-SVC-01 conversation service, NC-SVC-11 database…). Useful for the ticket; do not branch on it. |
failure_kind | How the connection failed: connect_timeout, read_timeout, stream_interrupted, store_unavailable… |
operation_outcome | not_started (nothing left), unknown (it left and it is unknown whether it applied) or partial. |
retry_action | The deciding field: retry (repeating is safe), check_status (check the state before repeating) or none. |
retry_after_s | Only with retry_action: "retry", and then it also travels as the Retry-After header. |
persisted / persistence_failed | Present when what failed was saving the outcome. |
retryable is about the connection
A dispatched turn may have called the model and run tools before being cut. That is why its errors arrive with retryable: false and retry_action: "check_status": repeating it blindly could duplicate an effect that did happen.
By HTTP status
| Status | What it means | Retry? |
|---|---|---|
400 | Something mandatory is missing from the request (typically the Idempotency-Key). | No, not without fixing it. |
401 | Token missing, invalid, expired or revoked. Also if the credential or its owner stopped being valid. | Yes, once, with a fresh token. If it repeats, it is the credential. |
403 | A scope is missing, or the named resource is outside the scope. | No: it is configuration. |
404 | It does not exist or it is outside the scope. Both cases answer the same, by design. | No. |
409 | State conflict: idempotency, turn in progress, chat busy, interaction already resolved or with an unknown outcome. | Depends on the code — see below. |
413 | The body exceeds the maximum. | No. |
422 | The body does not match the contract: unknown field, wrong type, value out of range, or a text with references that cannot be used. | No. |
429 | The request limit was exceeded. | Yes, honoring Retry-After. |
500 | Internal error. The body does not say why; the detail stays in our log, keyed by request_id. | Yes, with backoff. |
502 | The conversation service failed or did not answer. | Check the state first: the turn was dispatched and may have had effects. Then, with a new key. |
503 | A dependency is unavailable. meta.error_details says which one and whether repeating is safe. | Per retry_action: retry yes, with backoff; check_status, check first. |
504 | The turn exceeded its maximum duration. | Yes, with a new key. The previous turn is closed. |
API codes
| `message_code` | Status | When |
|---|---|---|
invalid_token | 401 | Covers every authentication rejection: missing header, invalid signature, expired or revoked token, stale credential, disabled user or company. The message tells the cases apart; the code does not. |
insufficient_scope | 403 | The operation requires a scope the token lacks. The WWW-Authenticate header names it. |
api_token_scope_not_allowed | 403 | A resource was attached without its companion scope. data.required_scopes lists them. |
api_v1_not_found | 404 | The resource does not exist, or is outside the credential's scope. |
api_v1_resource_not_allowed | 403 | The client explicitly named a resource it cannot use. |
api_v1_resource_policy_create_denied | 403 | The credential is in allowlist mode over that type and cannot create new resources. |
api_v1_validation_error | 422 | The body does not match the contract. data.errors lists field, message and type. |
payload_too_large | 413 | The body exceeds the maximum allowed. |
rate_limit_exceeded | 429 | The credential or IP bucket ran out. |
api_v1_internal_error | 500 | Unhandled error. Quote the request_id. |
Chat-specific
| `message_code` | Status | When and what to do |
|---|---|---|
api_v1_idempotency_key_required | 400 | The header is missing. One is never generated for you. |
api_v1_idempotency_conflict | 409 | The same key was used before with a different body. Use a new key: retrying with this one will always fail. |
api_v1_turn_in_progress | 409 | The turn for that key is still running. Retry with the same key later. |
api_v1_chat_busy | 409 | That chat already has a turn running, from the API or from the application (data.message_id says which). Wait, cancel it, or use another chat. |
api_v1_sync_requires_auto | 409 | The synchronous send requires permission_mode: "auto". For manual, use the stream. |
api_v1_content_too_long | 422 | content exceeds the maximum. data.max_chars states it. |
api_v1_too_many_attachments | 422 | Too many attachments. data carries the limit and the offending field. |
api_v1_metadata_too_large | 422 | Serialized metadata exceeds the maximum. |
api_v1_action_not_found | 404 | The interaction does not exist, expired, or is not yours. |
api_v1_action_not_pending | 409 | It was already resolved, another request claimed it first, or its outcome is unknown. Do not retry: applying the same approval twice is worse than not knowing. |
api_v1_action_result_unknown | 409 / 503 | The decision left and it cannot be confirmed it applied (503), or the service received and rejected it (409). Either way the interaction is closed: check the turn, do not resend it. |
api_v1_service_unavailable | 503 | The decision never left. The interaction goes back to pending and retrying is safe. |
api_v1_interaction_backend_unavailable | 503 | An outage on our side, not your mistake. Retry. |
api_v1_core_unavailable | 409 / 500 / 502 / 503 | A cancellation could not be delivered (502), or the synchronous turn could not confirm its close. data.code says which: persistence_failed, outcome_unknown or idempotency_outcome_unknown. In every case, check the state with the same key before retrying. |
turn_timeout | 504 | The synchronous turn exceeded its maximum duration and was cut off. data carries chat_id and message_id. |
upstream_error and other turn codes | 502 | A failed synchronous turn returns the turn's own code as message_code, with chat_id and message_id in data. It is exactly the body the key stores. |
interaction_in_auto_mode | 502 | The synchronous turn asked for an interaction that channel cannot answer, and it was cut. Use the stream. |
api_v1_library_embedding_unavailable | 503 | No embedding model is available to create libraries. |
Skill and rule specific
| `message_code` | Status | When and what to do |
|---|---|---|
prompt_refs_invalid | 422 | instructions cites resources that cannot be used. data[0] groups them into denied, invalid, unsupported, over_limit and cycle. Nothing was saved. See Resource references. |
prompt_text_too_long | 422 | instructions exceeds the maximum: 12,000 characters for a skill, 4,000 for a rule. data[0].too_long carries the limit and the length. |
OAuth errors
The /api/oauth/* endpoints do not use the envelope: they answer with the RFC shape, { "error", "error_description" }.
{
"error": "invalid_scope",
"error_description": "La credencial no tiene esos permisos: rules:write"
}| `error` | Status | When |
|---|---|---|
invalid_client | 401 | Invalid client or secret, revoked or expired credential, disabled user or company. One error for all of them. |
unsupported_grant_type | 400 | grant_type is not client_credentials. |
invalid_scope | 400 | A nonexistent scope was requested, or one the credential does not currently hold. |
invalid_request | 400 / 413 | Credentials through two channels at once, or an oversized form. |
server_error | 503 | Incomplete server configuration. Retry and let us know. |
Retrying properly
import random
import time
RETRYABLE = {429, 500, 502, 503, 504}
def with_retries(call, attempts: int = 4):
"""Reintenta con backoff exponencial y jitter.
Solo los status de `RETRYABLE`: un 4xx de contrato no mejora por insistir.
"""
for attempt in range(attempts):
response = call()
if response.status_code not in RETRYABLE or attempt == attempts - 1:
return response
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay + random.uniform(0, 0.5))
return responseRetrying a turn
A send that failed with 502 or 504 already consumed its `Idempotency-Key`: retrying with the same one returns the stored error, not a new turn. But that turn was dispatched and may have had effects (a tool that sent an email, for example): look at its messages first before generating a new key. api_v1_turn_in_progress and unconfirmed closes (persistence_failed, idempotency_outcome_unknown) ask you to repeat with the SAME key, to read what was stored.