Saltar al contenido principal

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.

typePara qué
textTexto libre. Solo dentro de la ventana de 24 horas
templatePlantilla aprobada. Abre conversación en cualquier momento
testIgual que template, con hello_world por defecto y errores claros

Todos aceptan los mismos campos base:

CampoObligatorioQué hace
typesítext, template o test.
tosíNúmero del destinatario en formato internacional, solo dígitos.
fromnophone_number_id de origen. Omitido, usa el número activo más antiguo.
contextMessageIdnowamid de un mensaje recibido: el tuyo aparece citándolo.
sendAtnoInstante ISO 8601 para enviar después. Ver Programar.
delaySecondsnoLo 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"
}
}
El texto libre exige la ventana abierta

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 prueba

Desde 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.

Dentro de dos horas
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.

Qué se vuelve a comprobar al enviar

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ónRespuesta
Primera llamadaLa respuesta normal
Misma clave, mismo cuerpoLa primera respuesta, con Idempotent-Replayed: true — no se envía nada
Misma clave, cuerpo distinto422 idempotency_key_reused
Misma clave, la primera sigue en curso409 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 502 porque Meta no respondió puede haberse entregado igualmente, y la repetición devuelve el mismo 502 en 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 sendAt con 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_:

Imagen por URL
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" }
}'
Botones de respuesta rápida
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" } }
]}
}
}'
Marcar como leído
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:

statusSignificado
sentSalió de la infraestructura de Meta
deliveredLlegó al dispositivo
readSe abrió (si el cliente no desactivó la confirmación)
failedNo 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.

Aceptado no es entregado

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.