Skip to Content
APIErrores y límites

Errores y límites

La forma de un error

Todos los errores de la API salen con el mismo cuerpo, sin importar la operación ni el código:

{ "error": { "code": "outside_messaging_window", "message": "La ventana de 24 horas está cerrada. Envía una plantilla aprobada.", "detail": { "last_inbound_at": "2026-09-03T14:22:00Z" }, "request_id": "req_794929f67ff26af6" } }
CampoQué es
codeEl contrato. Texto estable en snake_case. Es lo que tu integración debe comparar para decidir qué hacer.
messageTexto legible en español, pensado para quien lee un log. Puede cambiar sin aviso: no lo parsees.
detailDatos estructurados del caso puntual (el permiso que falta, los campos inválidos, el límite del cupo) o null.
request_idIdentificador de esa petición. Viaja además en el header X-Request-Id de toda respuesta, correcta o no.

Cuando algo no cuadre, pásanos el request_id. Con ese valor encontramos tu petición exacta en nuestros registros. También lo ves tú mismo en Configuración → API → Actividad reciente.

Ramifica en code, nunca en message ni solo en el estado HTTP. Varios códigos comparten estado (422 cubre desde un campo faltante hasta una plantilla pausada) y cada uno pide una acción distinta.

Tabla completa de códigos

400 La petición está mal formada

CódigoQué hacer
invalid_cursorEl cursor de paginación es ilegible o pertenece a otro filtro. Vuelve a empezar el listado sin cursor.

401 No sabemos quién eres

CódigoQué hacer
invalid_api_keyFalta el header Authorization, está mal formado, o la clave no existe, fue revocada o venció. Verifica el header y el estado de la clave en el panel. Es el mismo error para todos esos casos a propósito: distinguirlos le diría a un atacante qué claves existieron. La respuesta trae WWW-Authenticate: Bearer, por si tu cliente HTTP decide con ese header cuándo reintentar la autenticación.

402 Falta cupo

CódigoQué hacer
quota_exceededSe agotó el cupo mensual de plantillas de tu plan (el mismo que usan las campañas). detail trae limit y used. Espera al próximo ciclo o sube de plan. Nada se envió.

403 Sabemos quién eres, pero no puedes

CódigoQué hacer
missing_scopeLa clave no tiene el permiso que esa operación exige. detail.required_scope te dice cuál. Crea una clave nueva con los permisos correctos.
feature_not_in_planTu plan no incluye la API pública. Cambia a Growth o Business desde Facturación.

404 No existe

CódigoQué hacer
not_foundLa ruta no existe. Casi siempre es una URL mal armada: revisa el path y el prefijo /v1.
channel_not_foundEse channel_id no existe en tu cuenta o el canal fue eliminado. Vuelve a pedir GET /v1/channels.
message_not_foundEse mensaje no existe, o existe pero no lo enviaste por la API. GET /v1/messages/{id} solo expone tus envíos por API.
contact_not_foundEse contact_id no existe en tu cuenta. Cada recurso tiene su propio código para que puedas distinguir “el id no existe” de “armé mal la URL” (not_found).
group_not_foundEse group_id no existe en tu cuenta o el grupo fue eliminado. Vuelve a pedir GET /v1/groups: guarda el id del grupo en tu configuración, nunca su nombre.

405 Método equivocado

CódigoQué hacer
method_not_allowedEsa ruta existe pero no acepta ese método. Revisa el verbo HTTP.

409 Conflicto con algo en curso

CódigoQué hacer
idempotency_in_flightHay otra petición con la misma Idempotency-Key procesándose ahora mismo. Espera unos segundos y reintenta con la misma clave.

422 La petición se entiende, pero no puede ejecutarse

CódigoQué hacer
validation_errorEl cuerpo o los parámetros no cumplen el esquema. detail es una lista de {field, issue} con cada problema. Incluye tres casos que conviene tener presentes: un campo que no existe en el contrato (un typo como "nmae" no se ignora, se rechaza con su nombre), un parámetro de query inventado (?page=2), y un valor demasiado largo (ver los topes más abajo). Si el JSON ni siquiera se pudo leer, field es body.
idempotency_key_requiredFalta el header Idempotency-Key, obligatorio para enviar mensajes.
idempotency_key_reusedEsa clave ya se usó con un JSON distinto (comparamos el cuerpo como JSON, no como texto: reordenar claves o cambiar espacios no cuenta como distinto). Si de verdad es el mismo envío, revisa qué campo cambió; no acuñes una clave nueva sin confirmar que es otro envío, porque eso enviaría el mensaje dos veces.
channel_not_supportedEl canal no admite envíos por API. Hoy solo WhatsApp los admite.
channel_not_connectedEl canal de WhatsApp perdió la conexión o le falta configuración. Vuelve a conectarlo desde el panel.
invalid_phoneEl teléfono no es un número internacional válido. Mándalo con indicativo (+573001234567).
template_not_foundNo hay plantilla aprobada con ese nombre e idioma en ese canal. También sale si mandas name o language vacíos (omitirlos da validation_error).
template_not_approvedLa plantilla existe pero no se puede usar: está pendiente de aprobación, pausada, rechazada o deshabilitada. detail.template_status trae el estado que reportó Meta. Se resuelve en Meta Business.
template_variables_mismatchLas variables no coinciden con las que la plantilla espera. Revisa cantidad, nombres y largo de cada valor.
recipient_opted_outEl destinatario apagó las promociones. Puedes escribirle con una plantilla de utilidad o autenticación.
recipient_unreachableEse número no puede recibir mensajes de WhatsApp.
outside_messaging_windowPediste texto libre y la ventana de 24 horas está cerrada. Envía una plantilla aprobada. detail.last_inbound_at trae la fecha del último mensaje del cliente, o null si nunca escribió.
payment_method_requiredTu cuenta de WhatsApp Business no tiene método de pago en Meta. Agrégalo en Meta Business.

429 Vas demasiado rápido

En los tres, si era un envío, reintenta con la misma Idempotency-Key: un 429 libera la clave, así que el reintento se ejecuta de verdad.

Los dos primeros son límites nuestros y traen Retry-After en segundos. El tercero viene de WhatsApp y no lo trae: ahí aplica tu propia espera creciente.

Código¿Trae Retry-After?Qué hacer
rate_limitedSuperaste el límite de peticiones de tu cuenta. Espera lo que indique y reintenta.
channel_throttledEse canal de WhatsApp superó su tope de envíos por minuto. Espera lo que indique y reintenta.
tier_exhaustedNoTu número alcanzó el máximo de personas nuevas que WhatsApp le permite contactar en 24 horas. detail trae tier, capacity y used_24h. No es por minuto y no se arregla esperando un rato: el cupo se libera de a poco durante la ventana. Consúltalo con GET /v1/channels/{id}/capacity antes de una tanda grande, y mira Grupos.
provider_rate_limitedNoWhatsApp limitó los envíos por volumen. Espera con backoff propio (arranca en unos minutos) y reintenta. No leas Retry-After en este caso: no viene.

500 y 502 El problema es nuestro o del proveedor

CódigoQué hacer
internal_errorError interno. Reintenta; si persiste, escríbenos con el request_id.
delivery_failedNo pudimos confirmar el envío con WhatsApp. Reintenta con la misma Idempotency-Key: un 502 libera la clave, así que el reintento se ejecuta de verdad. detail puede traer provider_code, el código de Meta, útil para soporte.
provider_unavailableEl equivalente en una lectura: no pudimos consultarle algo a WhatsApp (error del proveedor o timeout). No hay envío involucrado, así que no hay duplicado posible ni clave que reusar: reintenta con backoff. Lo emite hoy el listado de plantillas.

delivery_failed no garantiza que el mensaje no haya salido. Casi siempre es que no salió, pero si la llamada a WhatsApp se cortó por timeout o problema de red, WhatsApp pudo haberla aceptado sin que su respuesta nos llegara. Reintenta siempre con la misma Idempotency-Key: es lo único que cubre el otro escenario, aquel en que la respuesta perdida fue la nuestra y tu envío ya está registrado de este lado. Acuñar una clave nueva convierte una duda en un duplicado seguro.

Si alguna vez recibes un code que no está en esta tabla, trátalo por su estado HTTP: 4xx significa “corrige algo antes de reintentar” y 5xx significa “reintenta”. Nunca hagas fallar tu integración solo porque el código es desconocido.

Límites de uso

Por cuenta y por minuto:

Tipo de operaciónLímite
Lecturas (GET)300 peticiones por minuto
Envíos (POST /v1/messages)120 peticiones por minuto

El límite es por cuenta, no por clave: varias claves comparten la misma cuota, a propósito, para que crear una clave nueva no sea una forma de esquivarlo.

Además hay un tope de 60 envíos por minuto y por canal de WhatsApp, independiente de tu cuota. Existe para que varias claves de la misma cuenta no se sumen sobre un mismo número, y para que un bucle con reintentos no lo sature. Cuando muerde, la respuesta es 429 channel_throttled aunque te sobre cuota de cuenta.

Los headers de cuota

Las respuestas correctas traen:

X-RateLimit-Limit: 60 X-RateLimit-Remaining: 59 X-RateLimit-Reset: 1788564060

X-RateLimit-Reset es el momento en que el contador vuelve a cero, en segundos desde la época Unix. Al superar cualquiera de los dos límites de arriba, la respuesta es 429 (rate_limited o channel_throttled), repite esos headers y agrega Retry-After con los segundos que faltan.

No todas las respuestas de error incluyen los headers de cuota (una petición rechazada antes de contarse no consume nada), así que no dependas de su presencia para decidir.

Reintenta siempre con espera creciente, no en bucle cerrado. Usa Retry-After cuando venga, pero lee el header de forma defensiva: provider_rate_limited es un 429 que no lo trae, y un cliente que hace sleep(headers["Retry-After"]) a ciegas se rompe justo cuando WhatsApp lo está limitando.

Topes de los campos

Todo lo que mandas tiene un largo máximo. Pasarse responde 422 validation_error con el campo señalado en detail, antes de tocar WhatsApp: no gasta cupo ni consume el tope de envíos de tu canal.

CampoMáximoPor qué
Idempotency-Key (header)255Es lo que guardamos para reconocer tu reintento.
to.phone32Un número internacional entra de sobra; el formato lo valida invalid_phone.
contact.name255Es el largo del nombre en tu CRM.
content.text4000Tope de un mensaje de texto de WhatsApp.
content.name (plantilla)512Nombre de la plantilla en Meta.
content.language32Código de idioma, del estilo es o en_US.
content.variables64 claves, 64 caracteres por clave, 1024 por valorEl tope por valor es el del parámetro ya sustituido en el cuerpo de la plantilla; pasarse lo rechazaría WhatsApp con template_variables_mismatch después de haber gastado el envío.

Los valores de content.variables son siempre texto. Un número o un booleano sin comillas ({"1": 1500}) se rechaza con validation_error: conviértelo tú, para que el formato que ve tu cliente final sea el que tú elegiste y no el que elija un serializador.

Paginación

Los listados (GET /v1/contacts, GET /v1/channels, GET /v1/groups y sus integrantes) paginan por cursor, nunca por número de página. Un cursor es un texto opaco: no lo interpretes ni lo construyas, solo devuélvelo tal cual.

curl "https://api.alttos.ai/api/public/v1/contacts?limit=2" \ -H "Authorization: Bearer $ALTTOS_API_KEY"
{ "data": [ { "id": "c16c6b03-3990-4097-9819-5f80ca66fefb", "name": "Contacto 1", "phone": "573000000001", "email": "c1@ejemplo.com", "tags": ["api"], "created_at": "2026-09-04T23:20:30.196822Z", "last_activity_at": "2026-09-04T23:20:30.196822Z" }, { "id": "6b376bb0-21b8-4ad2-81f3-86e25ad0670e", "name": "Contacto 2", "phone": "573000000002", "email": "c2@ejemplo.com", "tags": ["api"], "created_at": "2026-09-04T23:20:30.196822Z", "last_activity_at": "2026-09-04T23:20:30.196822Z" } ], "next_cursor": "eyJ2IjoxLCJzb3J0IjoiY3JlYXRlZF9hdCIsImsiOlsiMjAyNi0wOS0wNFQyMzoyMDozMC4xOTY4MjIrMDA6MDAiLCI2YjM3NmJiMC0yMWI4LTRhZDItODFmMy04NmUyNWFkMDY3MGUiXX0" }

Para la página siguiente, pasa ese valor en cursor:

curl "https://api.alttos.ai/api/public/v1/contacts?limit=2&cursor=eyJ2IjoxLCJzb3J0Ijoi..." \ -H "Authorization: Bearer $ALTTOS_API_KEY"

Cuando next_cursor es null, llegaste al final. limit acepta entre 1 y 100 (por defecto 50); fuera de ese rango la respuesta es 422 validation_error.

Los contactos vienen del más reciente al más antiguo. El cursor apunta a una posición concreta del listado, no a un número de página, así que recorrer todas las páginas no salta ni repite elementos aunque se creen contactos mientras recorres.

GET /v1/channels pagina igual, con los mismos limit y cursor, pero del más antiguo al más reciente. Un cursor pertenece a la lista que lo emitió: pasarle a /v1/channels uno de /v1/contacts responde 400 invalid_cursor en vez de servir una página incoherente.

Buscar en vez de recorrer

Si lo que quieres es un contacto puntual, no recorras el listado: busca por coincidencia exacta.

# Por teléfono (acepta espacios, paréntesis y el prefijo +; comparamos por dígitos) curl "https://api.alttos.ai/api/public/v1/contacts?phone=%2B573000000000" \ -H "Authorization: Bearer $ALTTOS_API_KEY" # Por email (no distingue mayúsculas) curl "https://api.alttos.ai/api/public/v1/contacts?email=ana%40ejemplo.com" \ -H "Authorization: Bearer $ALTTOS_API_KEY"

La respuesta tiene la misma forma que el listado: data con lo que coincida (vacío si nada coincide) y next_cursor.

Compatibilidad

Dentro de /v1 solo hacemos cambios aditivos: campos nuevos en una respuesta, parámetros opcionales nuevos, códigos de error nuevos para casos que antes salían más genéricos. Nunca quitamos un campo ni le cambiamos el significado.

Para que eso sea seguro para ti, tu integración tiene que cumplir cuatro reglas:

  1. Ignora los campos que no conozcas. Un parser que falla ante una clave nueva convierte un cambio compatible en una caída. Lo mismo con los valores: kind, status y capabilities.send pueden crecer; trata como no soportado el que no reconozcas.
  2. Ramifica en code, no en message. El texto cambia; el código no.
  3. Trata un code desconocido por su estado HTTP. 4xx es “corrige”; 5xx es “reintenta”.
  4. Manda exactamente lo que el contrato declara. Al revés que al leer, al escribir no somos tolerantes: un campo de más o un parámetro de query inventado salen como 422 validation_error. Es a propósito — un campo ignorado en silencio envía el mensaje sin el dato que creías estar mandando.

Un cambio que sí rompa el contrato saldría bajo un prefijo nuevo (/v2), con aviso previo. /v1 no se rompe.

Last updated on