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"
}
}| Campo | Qué es |
|---|---|
code | El contrato. Texto estable en snake_case. Es lo que tu integración debe comparar para decidir qué hacer. |
message | Texto legible en español, pensado para quien lee un log. Puede cambiar sin aviso: no lo parsees. |
detail | Datos estructurados del caso puntual (el permiso que falta, los campos inválidos, el límite del cupo) o null. |
request_id | Identificador 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ódigo | Qué hacer |
|---|---|
invalid_cursor | El 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ódigo | Qué hacer |
|---|---|
invalid_api_key | Falta 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ódigo | Qué hacer |
|---|---|
quota_exceeded | Se 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ódigo | Qué hacer |
|---|---|
missing_scope | La 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_plan | Tu plan no incluye la API pública. Cambia a Growth o Business desde Facturación. |
404 No existe
| Código | Qué hacer |
|---|---|
not_found | La ruta no existe. Casi siempre es una URL mal armada: revisa el path y el prefijo /v1. |
channel_not_found | Ese channel_id no existe en tu cuenta o el canal fue eliminado. Vuelve a pedir GET /v1/channels. |
message_not_found | Ese 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_found | Ese 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_found | Ese 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ódigo | Qué hacer |
|---|---|
method_not_allowed | Esa ruta existe pero no acepta ese método. Revisa el verbo HTTP. |
409 Conflicto con algo en curso
| Código | Qué hacer |
|---|---|
idempotency_in_flight | Hay 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ódigo | Qué hacer |
|---|---|
validation_error | El 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_required | Falta el header Idempotency-Key, obligatorio para enviar mensajes. |
idempotency_key_reused | Esa 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_supported | El canal no admite envíos por API. Hoy solo WhatsApp los admite. |
channel_not_connected | El canal de WhatsApp perdió la conexión o le falta configuración. Vuelve a conectarlo desde el panel. |
invalid_phone | El teléfono no es un número internacional válido. Mándalo con indicativo (+573001234567). |
template_not_found | No 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_approved | La 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_mismatch | Las variables no coinciden con las que la plantilla espera. Revisa cantidad, nombres y largo de cada valor. |
recipient_opted_out | El destinatario apagó las promociones. Puedes escribirle con una plantilla de utilidad o autenticación. |
recipient_unreachable | Ese número no puede recibir mensajes de WhatsApp. |
outside_messaging_window | Pediste 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_required | Tu 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_limited | Sí | Superaste el límite de peticiones de tu cuenta. Espera lo que indique y reintenta. |
channel_throttled | Sí | Ese canal de WhatsApp superó su tope de envíos por minuto. Espera lo que indique y reintenta. |
tier_exhausted | No | Tu 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_limited | No | WhatsApp 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ódigo | Qué hacer |
|---|---|
internal_error | Error interno. Reintenta; si persiste, escríbenos con el request_id. |
delivery_failed | No 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_unavailable | El 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ón | Lí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: 1788564060X-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.
| Campo | Máximo | Por qué |
|---|---|---|
Idempotency-Key (header) | 255 | Es lo que guardamos para reconocer tu reintento. |
to.phone | 32 | Un número internacional entra de sobra; el formato lo valida invalid_phone. |
contact.name | 255 | Es el largo del nombre en tu CRM. |
content.text | 4000 | Tope de un mensaje de texto de WhatsApp. |
content.name (plantilla) | 512 | Nombre de la plantilla en Meta. |
content.language | 32 | Código de idioma, del estilo es o en_US. |
content.variables | 64 claves, 64 caracteres por clave, 1024 por valor | El 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:
- 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,statusycapabilities.sendpueden crecer; trata como no soportado el que no reconozcas. - Ramifica en
code, no enmessage. El texto cambia; el código no. - Trata un
codedesconocido por su estado HTTP.4xxes “corrige”;5xxes “reintenta”. - 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.