Skip to Content
APIEnvío de mensajes

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:

  1. Una API key con el permiso messages:send (y messages:read si vas a consultar el estado). Ver Autenticación.
  2. El channel_id de un canal de WhatsApp conectado. Lo obtienes con GET /v1/channels; sirve el que tenga status: "active" y template dentro de capabilities.send.
  3. 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

CampoObligatorioQué es
channel_idUUID del canal de WhatsApp desde el que sale el mensaje.
to.phoneTeléfono del destinatario en formato internacional (+573001234567). Aceptamos espacios y paréntesis; lo que importa son los dígitos, entre 7 y 15.
contact.nameNoNombre 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.typetemplate o text.
content.nameSi es plantillaNombre exacto de la plantilla aprobada en Meta.
content.languageSi es plantillaCódigo de idioma de la plantilla (es, es_MX, en_US, …). Tiene que coincidir con el de la plantilla aprobada.
content.variablesNoValores de las variables del cuerpo de la plantilla. Ver abajo.
content.textSi es textoEl 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 4567 sí se encuentra).
  • Si no existe, se crea con el contact.name que 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: true

Nada 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ónRespuesta
Misma clave, mismo JSON, la primera ya terminóLa respuesta original, tal cual, con Idempotency-Replayed: true.
Misma clave, JSON distinto422 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 curso409 idempotency_in_flight. Espera unos segundos y reintenta con la misma clave.
El envío falló con 429 o 5xxLa 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ísticoLa 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 sentdeliveredread, 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ódigoEstadoQué hacer
idempotency_key_required422Agrega el header Idempotency-Key.
idempotency_key_reused422Esa 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_flight409Hay una petición idéntica en curso. Espera unos segundos y reintenta con la misma clave.
validation_error422El cuerpo no cumple el esquema. detail trae la lista de campos con su problema.
channel_not_found404Ese channel_id no existe en tu cuenta, o el canal fue eliminado. Vuelve a pedir GET /v1/channels.
channel_not_supported422El canal no es de WhatsApp. Hoy solo WhatsApp admite envíos por API.
channel_not_connected422El 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_phone422El teléfono no es un número internacional válido. Mándalo con indicativo de país (+573001234567).
template_not_found422No hay plantilla aprobada con ese nombre e idioma en ese canal. Revisa que name y language coincidan exactamente con la de Meta.
template_not_approved422La plantilla existe pero no está aprobada: pendiente, pausada, rechazada o deshabilitada. detail.template_status trae el estado. Se resuelve en Meta Business.
template_variables_mismatch422Las 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_out422El destinatario apagó las promociones. Puedes escribirle con una plantilla de utilidad o autenticación.
recipient_unreachable422Ese número no puede recibir mensajes de WhatsApp. Verifícalo con el cliente.
outside_messaging_window422Pediste texto libre y la ventana está cerrada. Envía una plantilla aprobada.
payment_method_required422Tu cuenta de WhatsApp Business no tiene método de pago en Meta. Agrégalo en Meta Business.
quota_exceeded402Se agotó el cupo mensual de plantillas del plan. Espera al próximo ciclo o sube de plan.
rate_limited429Vas muy rápido. Espera lo que dice Retry-After y reintenta con la misma clave de idempotencia.
channel_throttled429Ese canal superó su tope de envíos por minuto. Espera lo que dice Retry-After y reintenta con la misma clave.
provider_rate_limited429WhatsApp 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_failed502No 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.

Last updated on