Enviar mensajes
Hay un endpoint de envío: POST /messages. Lo que cambia entre un texto y una
plantilla es el campo type del cuerpo, no la URL.
type | Para qué |
|---|---|
text | Texto libre. Solo dentro de la ventana de 24 horas |
template | Plantilla aprobada. Abre conversación en cualquier momento |
test | Igual que template, con hello_world por defecto y errores claros |
Todos aceptan los mismos campos base:
| Campo | Obligatorio | Qué hace |
|---|---|---|
type | sí | text, template o test. |
to | sí | Número del destinatario en formato internacional, solo dígitos. |
from | no | phone_number_id de origen. Omitido, usa el número activo más antiguo. |
contextMessageId | no | wamid de un mensaje recibido: el tuyo aparece citándolo. |
sendAt | no | Instante ISO 8601 para enviar después. Ver Programar. |
delaySeconds | no | Lo mismo, en segundos a partir de ahora. |
La respuesta 201 trae el WAMID, el identificador que casa el mensaje con los
eventos de estado que llegan después por webhook.
{
"data": {
"messages": [{ "id": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF" }],
"from": "106540352242922",
"to": "5541987111527"
}
}
Solo las plantillas funcionan fuera de las 24 horas. Ver La ventana de 24 horas.
Texto
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Tu pedido #1042 ha salido para entrega.",
"previewUrl": true
}'
Hasta 4096 caracteres. previewUrl: true genera la tarjeta de vista previa del primer
enlace — Meta necesita poder acceder públicamente a la URL para montarla.
Plantilla
La única forma de escribir fuera de la ventana de 24 horas.
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "template",
"to": "5541987111527",
"name": "confirmacion_pedido",
"language": "es_ES",
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "María" },
{ "type": "text", "text": "1042" }
]}
]
}'
El orden de los parameters sigue la numeración de las variables ({{1}}, {{2}}). Una
plantilla sin variables no necesita components. Detalles en
Plantillas.
Prueba
Para responder «¿mi número está conectado y envía?» sin componer nada:
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "type": "test", "to": "5541987111527" }'
El valor por defecto es hello_world, que sale sin variables.
hello_world solo sale de un número de pruebaDesde un número de producción Meta lo rechaza con (#131058). Es una regla sobre el
origen del número: ni el token ni el payload lo resuelven. En ese caso indica name,
language y components de una plantilla aprobada tuya; la API traduce ese error
(type: "test_number_only") diciendo exactamente esto.
Programar
Los mismos envíos aceptan sendAt (instante absoluto) o delaySeconds (relativo). Los
dos existen porque los dos casos existen: «el viernes a las 9» es una fecha, «cinco
minutos después del carrito abandonado» es un retraso — y convertir uno en otro es donde
aparecen los errores de zona horaria.
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Tu carrito sigue reservado 🙂",
"delaySeconds": 7200
}'
La respuesta es 202, no 201: aceptamos la petición, pero todavía no existe nada en
Meta.
{
"data": {
"id": "clz3k1p9x0000",
"status": "pending",
"from": "106540352242922",
"to": "5541987111527",
"sendAt": "2026-08-15T21:00:00.000Z",
"messageId": null
}
}
Listar, consultar y cancelar:
curl https://api.joinotify.com/messages/scheduled?status=pending \
-H 'Authorization: Bearer sk_live_xxx'
curl -X DELETE https://api.joinotify.com/messages/scheduled/clz3k1p9x0000 \
-H 'Authorization: Bearer sk_live_xxx'
Cancelar vale incluso a segundos de la hora: quien entrega relee el registro antes de
enviar. Un mensaje ya enviado responde 404: no hay nada que cancelar.
Tres cosas, ninguna en el momento de programar: el derecho de uso (una suscripción
que caducó por el camino impide el envío), el número (puedes haber desconectado la
cuenta) y la cancelación. Un bloqueo por facturación no se reintenta: el mensaje
falla con el motivo en failureReason y queda en el historial.
Reintentar sin duplicar
La conexión se cayó después de que Meta aceptara el mensaje, y tu integración nunca vio la respuesta. Repetir la llamada, sin nada más, es un segundo mensaje de WhatsApp a la misma persona — un código enviado dos veces, una promoción entregada por duplicado.
La cabecera Idempotency-Key lo resuelve. Genera un valor por operación — el número del
pedido y el evento, por ejemplo — y envía el mismo valor en todos los intentos:
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: pedido-1042-confirmacion' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Pedido 1042 confirmado ✅"
}'
| Situación | Respuesta |
|---|---|
| Primera llamada | La respuesta normal |
| Misma clave, mismo cuerpo | La primera respuesta, con Idempotent-Replayed: true — no se envía nada |
| Misma clave, cuerpo distinto | 422 idempotency_key_reused |
| Misma clave, la primera sigue en curso | 409 idempotency_in_progress — espera unos segundos y repite |
Lo que conviene saber antes de confiar en ello:
- La clave tiene de 8 a 128 caracteres: letras, dígitos,
_,-,.y:. - Dura 24 horas y es de la cuenta, no de la API key: dos integraciones de la misma cuenta que usen el mismo valor se encuentran.
- Toda respuesta final se guarda, errores incluidos. Un
502porque Meta no respondió puede haberse entregado igualmente, y la repetición devuelve el mismo502en lugar de arriesgar un segundo envío. Para reintentar de verdad, usa una clave nueva — sabiendo que puede duplicar. - El cuerpo se compara tal como llegó: serializa el JSON siempre igual, o el mismo mensaje con los campos en otro orden se vuelve "otro cuerpo".
- También vale para programaciones: repetir un
sendAtcon la misma clave no crea una segunda programación.
Los otros tipos de mensaje
Imagen, vídeo, audio, documento, sticker, ubicación, contacto, reacción, botones, lista,
CTA y carrusel no tienen atajo de alto nivel, y no lo necesitan. El espejo
POST /v1/{phone_number_id}/messages acepta el payload completo de la Cloud API, con
cualquier tipo que Meta admita, usando tu sk_live_:
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"to": "5541987111527",
"type": "image",
"image": { "link": "https://ejemplo.com/catalogo.jpg", "caption": "Catálogo de julio" }
}'
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"to": "5541987111527",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "¿Podemos entregar mañana entre las 9 y las 12?" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "confirma", "title": "Sí, perfecto" } },
{ "type": "reply", "reply": { "id": "cambiar", "title": "Quiero cambiarlo" } }
]}
}
}'
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF"
}'
El formato de cada tipo es el de la documentación de Meta, sin traducción nuestra de por medio: esa es la ventaja del espejo, lo que vale allí vale aquí.
Cuando el mismo archivo va a mucha gente, súbelo una vez con
POST /v1/{phone_number_id}/media y reutiliza el id. Con link, Meta descarga el
archivo de tu servidor en cada envío. Ver Multimedia.
Cuando el cliente toca un botón, llega un webhook messages con type: "interactive":
{
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirma", "title": "Sí, perfecto" }
}
}
Trata el id, no el title. El título es texto de interfaz y puede cambiar; el id es
el contrato.
Seguir la entrega
Ningún envío confirma la entrega: el 201 solo dice que WhatsApp aceptó el mensaje. Lo
que pasa después llega por webhook messages, en el array statuses:
status | Significado |
|---|---|
sent | Salió de la infraestructura de Meta |
delivered | Llegó al dispositivo |
read | Se abrió (si el cliente no desactivó la confirmación) |
failed | No se entregó: el motivo viene en errors[] |
Guarda el WAMID del 201 y cásalo con el id del estado. Ver Webhooks.
Si prefieres no mantener estado de tu lado — o estás depurando un envío aislado — consulta el último estado conocido de un WAMID:
curl https://api.joinotify.com/messages/status/wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF \
-H 'Authorization: Bearer sk_live_xxx'
{
"data": {
"messageId": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF",
"status": "failed",
"recipientId": "5541987111527",
"errors": [{ "code": 131047, "title": "Re-engagement message" }],
"updatedAt": "2026-07-29T01:51:43.030Z"
}
}
La respuesta sale del mismo espejo que alimenta Eventos recibidos: no cuesta
llamada a Meta ni cuota de envío. status: null significa que aún no ha llegado ningún
estado (suele tardar segundos) o que los webhooks de la cuenta no se están entregando.
Un texto libre fuera de la ventana de 24 horas se acepta (recibes 201 y un WAMID) y
se rechaza segundos después, con el código 131047 en el estado failed. Si tu
integración trata el 201 como éxito final, jurará que envió un mensaje que nunca llegó.
Ver La ventana de 24 horas.