Envío de mensajes
POST /v1/messages envía un mensaje de WhatsApp a un destinatario. Es la operación que motiva toda la API: tu sistema detecta que hay algo que avisar y lo avisa.
Lo primero que conviene entender es la regla que impone WhatsApp, porque decide qué puedes mandar:
- Fuera de la ventana de 24 horas (el caso normal de una alerta) solo se puede enviar una plantilla aprobada por Meta.
- Dentro de la ventana (el cliente te escribió hace menos de 24 horas) también puedes enviar texto libre.
La API valida esto por su cuenta y rechaza antes de tocar a WhatsApp. Nunca vas a recibir un “aceptado” sobre un mensaje que no salió: o el mensaje se envió de verdad, o tienes un error con el motivo exacto.
Antes de empezar
Necesitas tres cosas:
- Una API key con el permiso
messages:send(ymessages:readsi vas a consultar el estado). Ver Autenticación. - El
channel_idde un canal de WhatsApp conectado. Lo obtienes conGET /v1/channels; sirve el que tengastatus: "active"ytemplatedentro decapabilities.send. - Si vas a enviar plantilla, el nombre y el idioma de una plantilla ya aprobada en tu cuenta de Meta. Los obtienes con
GET /v1/channels/{channel_id}/templates, que además te dice cómo se llaman sus variables: ver Plantillas.
Enviar una plantilla
Es el caso principal. Funciona esté abierta o cerrada la ventana de 24 horas.
curl -X POST https://api.alttos.ai/api/public/v1/messages \
-H "Authorization: Bearer $ALTTOS_API_KEY" \
-H "Idempotency-Key: cita-48219-recordatorio" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "1b1a6d68-6e78-4717-aa04-5ace8614e907",
"to": { "phone": "+573001234567" },
"contact": { "name": "Ana Pérez" },
"content": {
"type": "template",
"name": "recordatorio_cita",
"language": "es",
"variables": { "1": "Ana", "2": "mañana a las 9:00" }
}
}'Respuesta 201:
{
"id": "48ccb5f5-27a6-440f-bce6-49b03bab4014",
"conversation_id": "8733af24-5f8d-42b3-a61a-ea2c2e171127",
"contact_id": "ee882a53-0923-4009-94a5-00024bd3436e",
"channel_id": "1b1a6d68-6e78-4717-aa04-5ace8614e907",
"status": "sent",
"provider_message_id": "wamid.HBgMNTczMDAxMjM0NTY3FQIAERgSMEFCQ0RFRjAxMjM0NTY3ODkA",
"created_at": "2026-09-04T23:20:51.158065Z"
}Guarda el id: es con lo que consultas el estado de entrega más adelante.
Enviar texto libre
Solo válido si el destinatario te escribió por ese canal en las últimas 24 horas. Si no, la API responde 422 outside_messaging_window y no se envía nada.
curl -X POST https://api.alttos.ai/api/public/v1/messages \
-H "Authorization: Bearer $ALTTOS_API_KEY" \
-H "Idempotency-Key: pedido-9931-despacho" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "1b1a6d68-6e78-4717-aa04-5ace8614e907",
"to": { "phone": "+573001234567" },
"content": { "type": "text", "text": "Tu pedido ya está en camino." }
}'La respuesta tiene exactamente la misma forma que la de plantilla.
Los campos de la request
| Campo | Obligatorio | Qué es |
|---|---|---|
channel_id | Sí | UUID del canal de WhatsApp desde el que sale el mensaje. |
to.phone | Sí | Teléfono del destinatario en formato internacional (+573001234567). Aceptamos espacios y paréntesis; lo que importa son los dígitos, entre 7 y 15. |
contact.name | No | Nombre con el que crear el contacto si todavía no existe. Sobre un contacto que ya está en tu CRM no se aplica: el nombre que tú tienes manda. |
content.type | Sí | template o text. |
content.name | Si es plantilla | Nombre exacto de la plantilla aprobada en Meta. |
content.language | Si es plantilla | Código de idioma de la plantilla (es, es_MX, en_US, …). Tiene que coincidir con el de la plantilla aprobada. |
content.variables | No | Valores de las variables del cuerpo de la plantilla. Ver abajo. |
content.text | Si es texto | El texto a enviar. Entre 1 y 4000 caracteres, y no puede ser solo espacios. |
No mandes campos de más. Lo que no esté en esta tabla se rechaza con 422 validation_error nombrando el campo sobrante, en vez de ignorarse. Es a propósito: un "nmae" ignorado en silencio envía la plantilla sin el nombre que creías estar mandando, y eso lo descubre tu cliente final. Cada campo tiene además un largo máximo: ver topes de los campos.
Las variables de la plantilla
Depende de cómo esté definida la plantilla en Meta:
- Posicional (lo habitual,
{{1}},{{2}}): las claves son los números como texto, en orden.{ "variables": { "1": "Ana", "2": "mañana a las 9:00" } } - Con nombre: las claves son los nombres declarados en la plantilla.
{ "variables": { "nombre": "Ana", "fecha": "mañana a las 9:00" } }
Los valores son siempre texto, entre comillas. Un número o un booleano crudo ({"1": 1500}) se rechaza con 422 validation_error: el formato de un importe o una fecha es una decisión tuya —1.500, $1,500.00, 1500— y no la de nuestro serializador, así que conviértelo tú antes de mandarlo.
Si mandas más, menos o distintas de las que la plantilla espera, la respuesta es 422 template_variables_mismatch y no se envía nada.
El contacto y la conversación
No tienes que crear nada antes de enviar:
- Si el teléfono ya existe en tus contactos, se usa ese contacto tal cual (comparamos por dígitos, así que un contacto guardado como
+57 300 123 4567sí se encuentra). - Si no existe, se crea con el
contact.nameque hayas mandado.
El mensaje abre (o continúa) el mismo hilo al que llegarían los mensajes entrantes de ese teléfono en ese canal. Consecuencia práctica: si el cliente responde, su respuesta cae en esa conversación, se ve en tu bandeja y la atiende lo que ese canal tenga configurado. Si el canal tiene la IA encendida, contesta tu agente; si está en modo manual, queda esperando a una persona. La API no cambia ese comportamiento ni tiene una opción para cambiarlo.
La ventana de 24 horas, en detalle
WhatsApp permite escribirle libremente a alguien solo durante las 24 horas siguientes a su último mensaje. Fuera de eso, exige una plantilla aprobada.
Cómo la calculamos: buscamos el último mensaje entrante de ese teléfono en ese canal, sin importar en qué estado esté la conversación en tu bandeja. Esto es deliberado y te favorece. Alttos cierra automáticamente los hilos de WhatsApp que llevan horas sin actividad, así que mirar solo la conversación “abierta” rechazaría envíos que WhatsApp sí acepta, justo en el caso más común: el cliente escribió, nadie alcanzó a responder, y tu sistema quiere responder ahora.
Cuando la ventana está cerrada, el error trae la fecha del último mensaje entrante para que puedas mostrarla o registrarla:
{
"error": {
"code": "outside_messaging_window",
"message": "La ventana de 24 horas está cerrada. Envía una plantilla aprobada.",
"detail": { "last_inbound_at": null },
"request_id": "req_794929f67ff26af6"
}
}last_inbound_at es null cuando esa persona nunca te escribió por ese canal.
Ante la duda, manda plantilla. Una plantilla funciona siempre; el texto libre solo a veces. Si tu sistema no sabe si la ventana está abierta, intentar con plantilla te evita tener que manejar el rechazo.
Plantillas, opt-out y cupo mensual
Tres cosas que conviene tener claras antes de poner esto en producción:
Las plantillas se aprueban en Meta, no en Alttos. La API resuelve la plantilla contra tu cuenta de WhatsApp en el momento del envío. Una plantilla que existe pero no está aprobada —pendiente, pausada, rechazada o deshabilitada— responde 422 template_not_approved antes de enviar nada, con el estado que reportó Meta en detail.template_status: hay que arreglarla en Meta Business, no en la request.
El opt-out de marketing se respeta. Si el destinatario apagó las promociones desde el control nativo de WhatsApp, una plantilla de categoría MARKETING se rechaza con 422 recipient_opted_out. Las plantillas de utilidad y autenticación (un recordatorio de cita, un código de verificación, un aviso de servicio) siguen llegando: ese control apaga promociones, no avisos.
Las plantillas consumen el cupo mensual de tu plan. Es el mismo cupo que usan las campañas de Marketing: son mensajes iniciados por tu negocio y a Meta le cuestan lo mismo, salgan de donde salgan. Cuando se agota, la respuesta es 402 quota_exceeded con el límite y lo consumido en detail, antes de enviar nada:
{
"error": {
"code": "quota_exceeded",
"message": "Se agotó el cupo mensual de envíos de tu plan. Espera al próximo ciclo o sube de plan.",
"detail": { "limit": 15000, "used": 15000 },
"request_id": "req_426acabfe081cd0a"
}
}El texto libre dentro de la ventana no consume cupo, igual que no lo consume una respuesta escrita a mano por alguien de tu equipo desde la bandeja.
Idempotencia
El header Idempotency-Key es obligatorio en POST /v1/messages. Sin él, la respuesta es 422 idempotency_key_required.
Existe para lo que pasa de verdad en producción: tu sistema hace la llamada, se corta la conexión antes de recibir la respuesta y no sabe si el mensaje salió. Con una clave de idempotencia puedes reintentar sin miedo a que tu cliente reciba el aviso dos veces.
Elige una clave que identifique el envío, no el intento. Algo derivado de tu propio dominio funciona mejor que un valor aleatorio: cita-48219-recordatorio, pedido-9931-despacho. Máximo 255 caracteres.
El alcance de la clave es tu cuenta, no la clave de API. Dos integraciones tuyas con claves de API distintas comparten el mismo espacio de idempotencia: si las dos usan recordatorio-123 para envíos diferentes, la segunda recibe 422 idempotency_key_reused. Si tienes más de un sistema enviando, ponle a cada uno su propio prefijo (crm:recordatorio-123, erp:recordatorio-123).
Un reintento debe repetir la misma clave y el mismo JSON. Comparamos el cuerpo como JSON, no como texto: cambiar el orden de las claves, los espacios o los saltos de línea no cuenta como un cuerpo distinto, así que reintentar funciona aunque tu cliente HTTP vuelva a serializar el payload en cada intento (varios lo hacen, y algunos lenguajes ni siquiera garantizan el orden de las claves de un diccionario entre corridas) — no hace falta guardar el texto exacto del primer intento. Lo que sí cuenta como un cuerpo distinto es un cambio real de contenido bajo la misma clave (otro destinatario, otra plantilla), y ahí la respuesta es 422 idempotency_key_reused.
Qué pasa al repetir
Repite la llamada con la misma clave y el mismo JSON. No hace falta que sea el mismo texto carácter por carácter — alcanza con que sea el mismo objeto.
curl -X POST https://api.alttos.ai/api/public/v1/messages \
-H "Authorization: Bearer $ALTTOS_API_KEY" \
-H "Idempotency-Key: cita-48219-recordatorio" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "1b1a6d68-6e78-4717-aa04-5ace8614e907",
"to": { "phone": "+573001234567" },
"contact": { "name": "Ana Pérez" },
"content": {
"type": "template",
"name": "recordatorio_cita",
"language": "es",
"variables": { "1": "Ana", "2": "mañana a las 9:00" }
}
}'Obtienes el mismo 201 con el mismo id, y un header que te avisa que es una repetición:
HTTP/1.1 201 Created
Idempotency-Replayed: trueNada se volvió a enviar: el cliente recibió un solo mensaje y tu bandeja tiene una sola burbuja. Tampoco se consumió cupo de plantillas del plan. Lo que sí cuenta es tu límite de envíos por minuto: una repetición es una llamada como cualquier otra, así que reintentar en bucle cerrado te lleva a un 429 igual.
Las reglas completas
| Situación | Respuesta |
|---|---|
| Misma clave, mismo JSON, la primera ya terminó | La respuesta original, tal cual, con Idempotency-Replayed: true. |
| Misma clave, JSON distinto | 422 idempotency_key_reused. Comprueba si de verdad cambiaste algo del envío (destinatario, plantilla): si no, es un bug de tu lado construyendo el body. Solo usa una clave distinta si es otro envío real. |
| Misma clave mientras la primera sigue en curso | 409 idempotency_in_flight. Espera unos segundos y reintenta con la misma clave. |
El envío falló con 429 o 5xx | La clave se libera: el reintento con la misma clave se ejecuta de nuevo de verdad. Es lo que quieres, porque en esos casos el problema es transitorio. |
El envío falló con un 4xx determinístico | La clave se congela: mientras no cambies la request, reintentar devuelve el mismo rechazo. Corrige lo que el code te indica y usa una clave nueva. |
Las claves se conservan 24 horas. Pasado ese plazo se olvidan, así que un reintento muy posterior se trata como un envío nuevo.
Una clave de idempotencia protege un envío durante 24 horas, no para siempre. Si tu lógica de negocio no debe avisar dos veces nunca, esa garantía tiene que vivir también en tu sistema.
Consultar el estado de entrega
WhatsApp reporta la entrega de forma asíncrona. El 201 significa “WhatsApp lo aceptó”, no “el cliente lo leyó”. Para ver cómo evolucionó, consulta el mensaje por su id (requiere messages:read):
curl https://api.alttos.ai/api/public/v1/messages/48ccb5f5-27a6-440f-bce6-49b03bab4014 \
-H "Authorization: Bearer $ALTTOS_API_KEY"{
"id": "48ccb5f5-27a6-440f-bce6-49b03bab4014",
"conversation_id": "8733af24-5f8d-42b3-a61a-ea2c2e171127",
"contact_id": "ee882a53-0923-4009-94a5-00024bd3436e",
"channel_id": "1b1a6d68-6e78-4717-aa04-5ace8614e907",
"status": "sent",
"failure_reason": null,
"created_at": "2026-09-04T23:20:51.158065Z"
}status avanza por sent → delivered → read, o cae en failed. Solo cuando es failed viene failure_reason con el motivo.
Este endpoint expone únicamente los mensajes que enviaste por la API. Un mensaje escrito a mano desde la bandeja, o generado por tu agente de IA, responde 404 message_not_found: la conversación es tuya, pero no es asunto de una integración.
Si vas a hacer polling, un ritmo razonable es consultar unos minutos después del envío y no cada segundo: los estados los reporta WhatsApp cuando quiere, y cada consulta cuenta contra tu límite de lecturas.
Códigos de error del envío
Todos los 4xx significan lo mismo en un punto: nada se envió. Puedes corregir y reintentar sin riesgo de duplicar.
El 502 delivery_failed es el único distinto, y conviene leerlo con precisión: significa que no pudimos confirmar el envío. Casi siempre es que no salió (WhatsApp rechazó la llamada o no contestó), pero si la falla fue de transporte, por ejemplo un timeout o la conexión cortada a mitad, WhatsApp pudo haberlo aceptado sin que su respuesta nos llegara. Por eso el 502 no es “nada se envió”: es “no sabemos”. La acción segura es reintentar con la misma Idempotency-Key, nunca acuñando una nueva.
| Código | Estado | Qué hacer |
|---|---|---|
idempotency_key_required | 422 | Agrega el header Idempotency-Key. |
idempotency_key_reused | 422 | Esa clave ya se usó con un JSON distinto (reordenar claves o espacios no cuenta como distinto). Comprueba si de verdad cambió algo del envío; no acuñes una clave nueva sin confirmarlo, porque eso sí enviaría el mensaje dos veces. |
idempotency_in_flight | 409 | Hay una petición idéntica en curso. Espera unos segundos y reintenta con la misma clave. |
validation_error | 422 | El cuerpo no cumple el esquema. detail trae la lista de campos con su problema. |
channel_not_found | 404 | Ese channel_id no existe en tu cuenta, o el canal fue eliminado. Vuelve a pedir GET /v1/channels. |
channel_not_supported | 422 | El canal no es de WhatsApp. Hoy solo WhatsApp admite envíos por API. |
channel_not_connected | 422 | El canal de WhatsApp perdió la conexión o le falta configuración. Vuelve a conectarlo desde el panel; reintentar no lo va a arreglar. |
invalid_phone | 422 | El teléfono no es un número internacional válido. Mándalo con indicativo de país (+573001234567). |
template_not_found | 422 | No hay plantilla aprobada con ese nombre e idioma en ese canal. Revisa que name y language coincidan exactamente con la de Meta. |
template_not_approved | 422 | La plantilla existe pero no está aprobada: pendiente, pausada, rechazada o deshabilitada. detail.template_status trae el estado. Se resuelve en Meta Business. |
template_variables_mismatch | 422 | Las variables no coinciden con las que la plantilla espera. Revisa cuántas son, cómo se llaman y el largo de cada valor. |
recipient_opted_out | 422 | El destinatario apagó las promociones. Puedes escribirle con una plantilla de utilidad o autenticación. |
recipient_unreachable | 422 | Ese número no puede recibir mensajes de WhatsApp. Verifícalo con el cliente. |
outside_messaging_window | 422 | Pediste texto libre y la ventana está cerrada. Envía una plantilla aprobada. |
payment_method_required | 422 | Tu cuenta de WhatsApp Business no tiene método de pago en Meta. Agrégalo en Meta Business. |
quota_exceeded | 402 | Se agotó el cupo mensual de plantillas del plan. Espera al próximo ciclo o sube de plan. |
rate_limited | 429 | Vas muy rápido. Espera lo que dice Retry-After y reintenta con la misma clave de idempotencia. |
channel_throttled | 429 | Ese canal superó su tope de envíos por minuto. Espera lo que dice Retry-After y reintenta con la misma clave. |
provider_rate_limited | 429 | WhatsApp limitó los envíos por volumen. Este 429 no trae Retry-After: espera con backoff propio (unos minutos) y reintenta con la misma clave. |
delivery_failed | 502 | No pudimos confirmar el envío con WhatsApp. Reintenta con la misma clave de idempotencia. |
La lista completa de códigos de toda la API, incluidos los que no son del envío, está en Errores y límites.