Ir al contenido

Mensajes de WhatsApp por plantilla

El canal de mensajería de WhatsApp permite despachar mensajes de plantilla ya aprobados por Meta por el número de WhatsApp de tu tenant, desde tu propio sistema y con una API key. Cada despacho queda registrado y se puede consultar después por su identificador.


Un despacho avanza hasta «entregado», y también se registra cuándo el destinatario lo leyó. Ese avance lo puedes consultar o recibir en tu webhook.

Cuando el endpoint responde 201, lo que la plataforma afirma es que Meta aceptó el mensaje y devolvió su identificador — todavía no que haya llegado al teléfono. Después llegan los acuses de WhatsApp y el estado avanza solo: submittedsentdelivered. El instante de lectura se guarda aparte: leer no es un estado. Pero una lectura implica la entrega: si WhatsApp informa que el mensaje se leyó y el acuse de entrega no había llegado, el mensaje avanza a delivered.

Si enciendes la notificación de estados en el webhook del canal, cada avance te llega como el evento whatsapp.mensaje.estado: ver Recibir el estado de tus despachos.

Y por el mismo camino llegan los mensajes que te escriben: se registran, se te cobran, y se entregan al webhook de recepción que configures para el canal.


Antes de despachar tu primer mensaje necesitas cuatro cosas:

1. El servicio WHATSAPP_MENSAJERIA habilitado

Sección titulada «1. El servicio WHATSAPP_MENSAJERIA habilitado»

El tenant tiene que tener contratado el servicio de mensajería de WhatsApp. Si no lo tiene, la llamada responde 403 diciéndolo.

El canal se conecta desde el panel, en Comunicación → Canales. Un canal que no quedó conectado —o cuyo token de Meta expiró— responde 422 al despachar.

3. Una API key con el rol WHATSAPP_MENSAJERIA_DESPACHO

Sección titulada «3. Una API key con el rol WHATSAPP_MENSAJERIA_DESPACHO»

No sirve FULL-API, y es a propósito. El rol es angosto: habilita despachar y consultar un despacho, y nada más. No habilita configurar el canal, conectar un número, consultar su salud ni administrar plantillas.

La llave la crea un administrador del tenant desde el panel, en Configuración → API Keys. En el desplegable de rol, el que corresponde es Despacho Mensajería WhatsApp (WHATSAPP_MENSAJERIA_DESPACHO).

Ver Autenticación para el manejo general de API keys.

Las plantillas las administra el tenant desde el panel, en el canal de WhatsApp → Plantillas. Esta guía no cubre cómo crearlas; lo que sí importa para integrar es:

  • La plantilla tiene que estar aprobada por Meta antes de despacharla. Una plantilla que no existe o no está aprobada produce un 422 al enviar.
  • Se identifica por el par nombre + idioma (templateName + languageCode), no por un id.
  • 🔴 Meta puede reclasificar la categoría de una plantilla. Está medido: una plantilla enviada como UTILITY volvió MARKETING. El tenant no controla la categoría, y la categoría es lo que Meta cobra y limita distinto. Si un despacho se te empieza a rechazar por límites, ésa es la primera hipótesis.

El identificador del canal va en la ruta. Lo obtienes del panel: es el segmento de la URL cuando abres el canal en Comunicación → Canales (/{tenantSlug}/comunicacion/canales/{channelId}).

La API key no lo lleva adentro: la llave no está atada a ningún canal, el canal lo determina la ruta y su pertenencia al tenant se comprueba en cada llamada.


POST /{tenantSlug}/canales/whatsapp/{channelId}/enviar
El canal es de este tenant, o no existe (404)
Se escribe la fila del registro (estado: queued)
POST a Meta con los componentes declarados
Meta acusa → fila con wamid, estado: submitted
Se emite el evento de cobro
Respuesta: { id, wamid, estado, ... }
Después, por su cuenta: los acuses de WhatsApp
avanzan el estado a sent → delivered, y llenan
la marca de lectura sin cambiar el estado

La respuesta es síncrona: cuando la recibes, el mensaje ya salió (o ya falló).

La fila del registro se escribe antes de llamar a Meta, a propósito: el envío es irreversible, así que un despacho que falla igual queda registrado, con la causa exacta que devolvió Meta, en vez de desaparecer.


POST /{tenantSlug}/canales/whatsapp/{channelId}/enviar
Authorization: Bearer {api_key}
Content-Type: application/json
{
"destinatario": "+56912345678",
"templateName": "documento_firmado_v1",
"languageCode": "es",
"componentes": {
"cuerpo": {
"variables": [
{ "posicion": 1, "valor": "María" },
{ "posicion": 2, "valor": "Contrato 4821" }
]
},
"boton": {
"indice": "0",
"variables": [
{ "posicion": 1, "valor": "f/abc123XYZ" }
]
}
},
"referenciaExterna": "contrato-4821"
}
CampoTipoRequeridoDescripción
destinatariostringNúmero del destinatario, con código de país
templateNamestringNombre exacto de la plantilla aprobada
languageCodestringIdioma exacto de la plantilla (es, es_CL, …)
componentesobjectNoLos componentes de la plantilla. Ver abajo
variablesarrayNoForma anterior: equivale a componentes.cuerpo.variables
referenciaExternastringNoTu referencia para reconocer el despacho. Ver abajo

referenciaExterna vuelve en la respuesta, en la consulta del despacho y en cada evento whatsapp.mensaje.estado. Hasta 128 caracteres: letras, dígitos, punto (.), guion bajo (_), dos puntos (:) y guion (-). Fuera de esa forma el despacho responde 400 y no se envía nada.

  • No pongas datos personales —RUT, teléfono, correo, nombre—: la referencia se registra en el log y se guarda junto al mensaje. Usa el identificador de tu sistema.
  • No se envía a Meta: el destinatario no la ve.
  • No es clave de idempotencia: dos despachos con la misma referencia son dos mensajes, y se cobran los dos.

El número se normaliza a sólo dígitos —se le quitan +, espacios y guiones— y no se le agrega código de país. Manda el número internacional completo:

Lo que mandasLo que sale a Meta
+5691234567856912345678
56 9 1234 567856912345678
912345678912345678 ❌ — sin código de país, Meta no lo va a resolver

La respuesta trae destinatarioNormalizado con el número tal como Meta lo normalizó, que puede diferir del que enviaste.


🔴 El contrato de variables: posicional POR COMPONENTE

Sección titulada «🔴 El contrato de variables: posicional POR COMPONENTE»

Es la parte que más confunde, y conviene leerla dos veces.

Una plantilla de WhatsApp puede tener variables en tres lugares distintos: el encabezado, el cuerpo y el botón de URL. Cada uno numera sus variables desde {{1}} por separado — así lo define la API de Meta, no es una decisión de esta plataforma.

Toma esta plantilla:

Cuerpo: Hola {{1}}, tu documento {{2}} está listo para firmar.
Botón: https://validafirma.cl/{{1}}

Hay tres valores que llenar, pero la variable del botón es su propio {{1}}, no el {{3}} del cuerpo. Un arreglo plano de tres variables no puede expresar esto.

{
"componentes": {
"cuerpo": {
"variables": [
{ "posicion": 1, "valor": "María" },
{ "posicion": 2, "valor": "Contrato 4821" }
]
},
"boton": {
"indice": "0",
"variables": [
{ "posicion": 1, "valor": "f/abc123XYZ" }
]
}
}
}

1. Cada componente numera desde {{1}}. El posicion es 1-based y corresponde al {{n}} de ese componente, no del mensaje completo.

2. El indice del botón viaja como cadena. "0", nunca 0. Es lo que exige Meta; declararlo como número produce un cuerpo que Meta rechaza, y el rechazo llega recién al despachar. El índice es la posición del botón en la plantilla: el primero es "0".

3. 🔴 Un componente que la plantilla no tiene se OMITE, no se manda vacío. Una plantilla sin botón rechaza el envío que lo incluya. No mandes "boton": { "indice": "0", "variables": [] } «por las dudas»: no es inofensivo, es un envío fallido.

Una plantilla sin botón, entonces, simplemente no declara ese componente:

{
"destinatario": "+56912345678",
"templateName": "aviso_simple_v1",
"languageCode": "es",
"componentes": {
"cuerpo": { "variables": [{ "posicion": 1, "valor": "María" }] }
}
}

Una plantilla sin ninguna variable se despacha sin componentes:

{
"destinatario": "+56912345678",
"templateName": "aviso_fijo_v1",
"languageCode": "es"
}

Si la plantilla lleva un encabezado de imagen, el componente acepta el identificador de material de Meta que tú ya subiste:

{
"componentes": {
"encabezado": { "mediaId": "1639350477750383" },
"cuerpo": { "variables": [{ "posicion": 1, "valor": "María" }] }
}
}

La plataforma acepta ese identificador; no lo produce. La subida del material a Meta la haces tú, contra la API de Meta. Y es el id del material —lo que Meta devuelve al subirlo—, nunca un header_handle (que es de la creación de la plantilla) ni una URL de descarga.

El campo variables sigue aceptado y es exactamente componentes.cuerpo.variables:

{
"destinatario": "+56912345678",
"templateName": "aviso_simple_v1",
"languageCode": "es",
"variables": [{ "posicion": 1, "valor": "María" }]
}
Ventana de terminal
curl -X POST "https://api.redcumbre.cl/{tenantSlug}/canales/whatsapp/{channelId}/enviar" \
-H "Authorization: Bearer {api_key}" \
-H "Content-Type: application/json" \
-d '{
"destinatario": "+56912345678",
"templateName": "documento_firmado_v1",
"languageCode": "es",
"componentes": {
"cuerpo": {
"variables": [
{ "posicion": 1, "valor": "María" },
{ "posicion": 2, "valor": "Contrato 4821" }
]
},
"boton": {
"indice": "0",
"variables": [{ "posicion": 1, "valor": "f/abc123XYZ" }]
}
}
}'

{
"success": true,
"data": {
"id": "cmtugt4ln0003se8nqas72beu",
"wamid": "wamid.HBgLNTY5ODQzMDYyNDQVAgARGBI0QTMxRjNBREJBNDEyMjMxQUYA",
"estado": "submitted",
"destinatarioNormalizado": "56912345678",
"sandbox": false,
"cobrado": true,
"noCobradoMotivo": null,
"referenciaExterna": "contrato-4821"
}
}
CampoTipoDescripción
idstringIdentificador de la fila del registro
wamidstringIdentificador del mensaje en Meta
estadostringsubmitted cuando Meta aceptó. Avanza a sent y delivered con los acuses. Ver estados
destinatarioNormalizadostringEl número tal como Meta lo normalizó
sandboxbooleantrue si el despacho fue simulado
cobradobooleanSi se emitió el evento de facturación
noCobradoMotivostring | nullsandbox o sin_suscripcion cuando cobrado es false
referenciaExternastring | nullLa que mandaste en el body, tal cual. null si no la mandaste
EstadoTerminalDescripción
queuedNoLa fila se escribió, el despacho está en curso
submittedNoAceptado por Meta. Todavía sin acuse
sentNoWhatsApp acusó que el mensaje salió
deliveredWhatsApp acusó la entrega al destinatario, o informó que lo leyó. Es hasta donde llega un saliente
rejectedMeta rechazó el envío por una causa de negocio
failedSí, salvo lecturaLa llamada falló sin respuesta de negocio de Meta, o WhatsApp acusó que no pudo entregarlo. Si después WhatsApp informa que se leyó, avanza a delivered

failed y rejected no son lo mismo, y la diferencia importa para atender un reclamo: rejected es «Meta dijo que no», failed es «no sabemos si Meta lo vio».


Cada vez que alguien le escribe al número del canal, la plataforma registra el mensaje, te lo cobra y te avisa al webhook que configures para ese canal.

El destino se configura desde el panel, en el canal: Canales → tu número → Webhook de recepción. No hay endpoint de API para configurarlo: es una superficie humana a propósito.

POST https://tu-sistema.cl/hooks/whatsapp
Content-Type: application/json
X-Webhook-Signature: t=1789008127,v1=3f2a…
{
"event": "whatsapp.mensaje.entrante",
"canalId": "080a7de6-02e2-4682-8c0a-e27ba4a2d623",
"contenidoIncluido": true,
"mensaje": {
"wamid": "wamid.HBgLNTY5ODQzMDYyNDQVAgARGBI…",
"de": "56984306244",
"tipo": "text",
"cuerpo": "hola, necesito ayuda",
"recibidoEn": "2026-09-10T02:42:07.000Z",
"respondeA": null,
"numeroReceptor": "15550823902"
}
}
CampoSiempre vieneQué es
eventSiempre whatsapp.mensaje.entrante
canalIdEl canal que recibió el mensaje. Es el mismo de la ruta de despacho
contenidoIncluidofalse cuando el mensaje no es texto: ahí cuerpo viene null
mensaje.wamidIdentificador del mensaje en WhatsApp. Es único: úsalo para deduplicar
mensaje.deQuién escribió, en formato internacional sin +
mensaje.tipoEl tipo tal como lo nombra WhatsApp: text, audio, image, location
mensaje.cuerpoNoEl texto. Sólo en los mensajes de texto; null en todos los demás
mensaje.recibidoEnCuándo lo recibió WhatsApp, en ISO-8601
mensaje.respondeANoEl wamid del mensaje al que responde, si responde a alguno
mensaje.numeroReceptorNoTu número, tal como WhatsApp lo declara

La cabecera X-Webhook-Signature viene en el formato t=<unix>,v1=<hmac>, donde hmac es un HMAC-SHA256 de <unix>.<cuerpo crudo> con el secreto del canal.

import { createHmac } from 'node:crypto';
function firmaValida(cabecera, cuerpoCrudo, secreto) {
const [t, v1] = cabecera.split(',');
const unix = t.replace('t=', '');
const esperado = createHmac('sha256', secreto)
.update(`${unix}.${cuerpoCrudo}`)
.digest('hex');
return v1 === `v1=${esperado}`;
}

Es el mismo formato que el resto de los webhooks de la plataforma. Verifica siempre: sin la firma, cualquiera que conozca tu URL puede mandarte un aviso.

Si tu endpoint no responde 2xx, se reintenta 12 veces con espera creciente de 1 s a 1 min: una ventana total de unos 6 minutos. Agotados los intentos, el mensaje queda marcado como perdido en el registro del canal —y ahí lo puedes ver— pero no se vuelve a intentar.

El mensaje se cobra igual, incluso si tu endpoint nunca lo aceptó: lo recibiste.

El mensaje se registra y se cobra, y no se entrega a ninguna parte. Lo ves en el panel, en el registro del canal, con el desenlace «Sin webhook configurado». No cae al webhook general del tenant: son cosas distintas y mezclarlas mandaría los mensajes de un número al sistema equivocado.


Además de los mensajes que te escriben, el webhook del canal puede recibir qué pasó con cada mensaje que despachaste: enviado, entregado o leído, o fallido con su causa. Es opcional y viene apagado.

En el panel, en el canal: Canales → tu número → Webhook de recepción, tarjeta «Estado de tus despachos», interruptor «Notificar el estado de tus despachos». La tarjeta aparece cuando el canal ya tiene un webhook configurado: los eventos llegan a esa misma URL, firmados con el mismo secreto.

Son unos tres avisos por mensaje. Encenderlo o apagarlo no cambia los mensajes que te escriben ni los ecos: siguen llegando igual.

POST https://tu-sistema.cl/hooks/whatsapp
Content-Type: application/json
X-Webhook-Signature: t=1789400356,v1=9c1d…
{
"event": "whatsapp.mensaje.estado",
"canalId": "601b51f8-4422-4394-85fb-9cfc3eb73f92",
"aviso": {
"avisoId": "3b9f0c6e-6a51-4c0e-9d1a-2f4b7c8e5a10",
"mensajeId": "cmu1eqvzj000ljw66po1sduu3",
"wamid": "wamid.HBgLNTY5MTIzNDU2NzgVAgARGBI…",
"referenciaExterna": "contrato-4821",
"procedencia": "api",
"plantilla": { "nombre": "documento_firmado_v1", "idioma": "es" },
"contraparteNormalizada": "56912345678",
"destinatarioProveedor": "56912345678",
"estado": "delivered",
"estadoCrudo": "read",
"estadoAnterior": "sent",
"secuencia": 40,
"ocurridoEn": "2026-09-14T15:39:15.000Z",
"recibidoEn": "2026-09-14T15:39:16.204Z",
"entregadoEn": "2026-09-14T15:39:15.000Z",
"leidoEn": "2026-09-14T15:39:15.000Z",
"causa": null,
"proveedor": {
"id": "wamid.HBgLNTY5MTIzNDU2NzgVAgARGBI…",
"status": "read",
"timestamp": "1789400355",
"recipient_id": "56912345678"
}
}
}
CampoSiempre vieneQué es
eventSiempre whatsapp.mensaje.estado
canalIdEl canal que despachó el mensaje
aviso.avisoIdIdentificador único del aviso. Úsalo para deduplicar
aviso.mensajeIdEl id que devolvió el despacho: la clave de GET …/mensajes/{id}
aviso.wamidIdentificador del mensaje en WhatsApp
aviso.referenciaExternaNoLa que mandaste al despachar, tal cual. null si no la mandaste
aviso.procedenciaSiempre api: sólo se notifican los mensajes que despachó la plataforma
aviso.plantillaNombre e idioma de la plantilla enviada
aviso.contraparteNormalizadaEl destinatario, en formato internacional sin +
aviso.destinatarioProveedorNoEl destinatario tal como lo informa WhatsApp. Puede diferir del anterior por la normalización del país
aviso.estadoEl estado del mensaje justo después de este aviso: sent, delivered o failed. No cambia si el evento se reintenta
aviso.estadoCrudoLo que WhatsApp mandó: sent, delivered, read o failed
aviso.estadoAnteriorNoEl estado del mensaje antes de este aviso
aviso.secuenciaEl orden del estado: a mayor secuencia, más avanzado
aviso.ocurridoEnCuándo ocurrió, según WhatsApp, en ISO-8601
aviso.recibidoEnCuándo lo recibió la plataforma
aviso.entregadoEnNoocurridoEn si este aviso produjo la entrega, acusada o implícita por una lectura. Si no, null
aviso.leidoEnNoocurridoEn si este aviso registró la lectura. Si no, null
aviso.causaNoSólo con estado: failed. Ver abajo
aviso.proveedorEl aviso de WhatsApp tal como llegó, sin sus datos de tarificación

Con estado: failed, causa trae la razón que dio WhatsApp:

{
"codigo": "131026",
"titulo": "Message undeliverable",
"detalle": "Message Undeliverable.",
"elegibleParaRespaldo": true,
"requiereIntervencion": false,
"desconocido": false
}

elegibleParaRespaldo dice si conviene enviar el mismo contenido por otro medio —un SMS, un correo—; requiereIntervencion, si hay que corregir algo antes de reintentar; y desconocido, si el código no está clasificado.

  1. Al menos una vez. El mismo aviso puede llegar más de una vez: deduplica por avisoId.
  2. Sin orden garantizado. WhatsApp desordena los avisos, y además un reintento puede llegar después de un aviso posterior. Aplica el estado de mayor secuencia y descarta el resto.
  3. read puede llegar sin delivered, pero el evento ya viene con estado: delivered. Un leidoEn con valor implica entregado.
  4. Tu temporizador manda. El evento acelera tu decisión, no la reemplaza. Un evento perdido (12 intentos en unos 6 minutos) nunca debe dejar un mensaje sin respaldo. Al vencer tu plazo, GET …/mensajes/{id} es la fuente de verdad.
  5. Responde 2xx a todo evento, también a los que ignoras. Con otra respuesta, la plataforma reintenta, deja el aviso perdido y alerta a operación.
  6. Los mensajes que te escriben y los ecos no cambian con esta opción.
  7. Sin eventos en sandbox ni para los rechazos del despacho: esos ya están en la respuesta del POST.
  8. Un destino por canal. Si tienes varios entornos, usa un canal por entorno.
  9. Cada intento se firma de nuevo. En X-Webhook-Signature: t=<unix>,v1=<hmac>, t es el instante de ese intento, y el HMAC-SHA256 se calcula sobre <t>.<cuerpo crudo> con el secreto del canal. Una ventana de tolerancia de ±300 s sobre t alcanza también para el intento 12. Verifica sobre los bytes crudos del cuerpo, nunca sobre un JSON vuelto a serializar: ver Cómo verificas que el aviso es nuestro.
  10. Toda respuesta que no sea 2xx se reintenta, incluidas 401 y 503, hasta el intento 12. Ningún status descarta el aviso en el primer intento. Un 401 persistente —por ejemplo, un secreto mal configurado en tu receptor— agota los 12 intentos y deja el aviso perdido.
  11. Responde rápido. Cada intento espera hasta 30 segundos. Un timeout cuenta como fallo y se reintenta. Guarda el aviso, responde, y procésalo después.
  12. Un reintento de un aviso que ya guardaste debe recibir 2xx: tu deduplicación por avisoId lo reconoce y responde éxito. Si respondes error, el aviso termina perdido aunque lo tengas.

Número en coexistencia con WhatsApp Business App

Sección titulada «Número en coexistencia con WhatsApp Business App»

Si conectaste el número en coexistencia —sigues atendiendo desde WhatsApp Business App en tu celular y además usas la plataforma—, hay dos cosas más que llegan a tu sistema y al registro del canal.

Cada mensaje que el negocio envía desde WhatsApp Business App (o un dispositivo vinculado) se registra en el canal, se cobra a un precio menor que un despacho y se avisa al mismo webhook de recepción, firmado igual y con los mismos reintentos, con el evento whatsapp.mensaje.eco:

{
"event": "whatsapp.mensaje.eco",
"canalId": "080a7de6-02e2-4682-8c0a-e27ba4a2d623",
"contenidoIncluido": true,
"mensaje": {
"wamid": "wamid.HBgLNTY5ODQzMDYyNDQVAgARGBI…",
"para": "56984306244",
"tipo": "text",
"cuerpo": "hola, te escribo desde el celular",
"enviadoEn": "2026-09-13T15:55:37.000Z",
"numeroEmisor": "56900000000"
}
}
CampoSiempre vieneQué es
eventSiempre whatsapp.mensaje.eco
canalIdEl canal del número
contenidoIncluidofalse cuando el mensaje no es texto: ahí cuerpo viene null
mensaje.wamidIdentificador del mensaje en WhatsApp. Es único: úsalo para deduplicar
mensaje.paraA quién se lo enviaste, en formato internacional sin +
mensaje.tipoEl tipo tal como lo nombra WhatsApp
mensaje.cuerpoNoEl texto, sólo en los mensajes de texto
mensaje.enviadoEnCuándo se envió desde la app, en ISO-8601
mensaje.numeroEmisorNoTu número, tal como WhatsApp lo declara

El event es lo que distingue un eco de un entrante: ramifica por event, no por la forma del cuerpo. Los mensajes que despacha la plataforma no generan eco.

Al conectar en coexistencia, la plataforma pide a WhatsApp tus contactos y hasta 180 días de historial de la app. El historial queda en el registro del canal con procedencia «Historial sincronizado», no se cobra y no se avisa a tu webhook.

El detalle de un mensaje trae el campo procedencia:

procedenciaQué esSe cobraSe avisa a tu webhook
apiLo despachó la plataforma (tu API key o el panel)Sólo su estado, whatsapp.mensaje.estado, si lo encendiste
webhookAlguien le escribió al númeroSí, whatsapp.mensaje.entrante
app_celularLo enviaste desde WhatsApp Business AppSí, precio de ecoSí, whatsapp.mensaje.eco
historialLlegó con la sincronización del historialNoNo

Si desconectas la plataforma desde WhatsApp Business App, WhatsApp nos avisa: el canal queda desvinculado, los administradores del tenant reciben un correo y una notificación, y el despacho responde 409 whatsapp_canal_desvinculado hasta que alguien vuelva a conectar el número desde el panel.


CódigoDescripciónQué significa para ti
400Payload inválidoFalta un campo, o mandaste variables y componentes juntos
401No autorizadoAPI key inválida, expirada o revocada
403ProhibidoEl rol de la key no alcanza este endpoint, o el servicio WHATSAPP_MENSAJERIA no está habilitado
404No encontradoEl canal no existe o no es de tu tenant — indistinguibles a propósito
409whatsapp_canal_desvinculadoEl número estaba en coexistencia y se desvinculó desde WhatsApp Business App. No se escribió ningún mensaje ni se llamó a WhatsApp. El administrador tiene que volver a conectarlo desde el panel
422Meta rechazó el envíoTrae la causa real de Meta. Ver abajo
429Límite de mensajería alcanzadoEs el límite del número de tu tenant, no un fallo de la plataforma

El 422 es el error que más vas a ver, y el cuerpo trae el texto exacto que devolvió Meta, no un mensaje genérico. El campo error te da un código con el que ramificar:

{
"statusCode": 422,
"error": "whatsapp_envio_rechazado",
"message": "(#132001) Template name does not exist in the translation",
"mensajeId": "cmtusmgup0003see2h7njed6t"
}

El mensajeId es la fila que quedó escrita con esta misma causa: pásaselo a GET /{tenantSlug}/canales/whatsapp/{channelId}/mensajes/{id} y te devuelve errorCodigo y errorMensaje sin que tengas que abrir el panel.

errorQué pasóQué hacer
whatsapp_envio_rechazadoProblema de la plantilla —no existe, no está aprobada, los parámetros no calzan— o la ventana de conversación de 24 h está cerradaLee message: viene de Meta y dice cuál de los casos es. No reintentes igual
whatsapp_token_invalidoEl token del número expiró o fue revocadoNo lo puedes arreglar desde tu sistema. El administrador del tenant tiene que reconectar el número en el panel
whatsapp_channel_sin_credencialesEl canal no está conectadoÍdem: se reconecta desde el panel

429 — el límite es del número, no de la plataforma

Sección titulada «429 — el límite es del número, no de la plataforma»
{
"statusCode": 429,
"error": "whatsapp_limite_alcanzado",
"message": "...",
"mensajeId": "cmtusmgup0003see2h7njed6t"
}

Meta le asigna a cada número un límite de mensajería que depende de su calidad y su historial, y ese límite es del tenant. La plataforma no tiene un rate limit propio sobre este endpoint: el 429 que recibes viene de Meta.

Es reintentable con backoff, pero no ese mismo segundo: si agotaste el límite del día, reintentar en un bucle sólo agrega llamadas rechazadas.

Un error de Meta que no cae en ninguna de las categorías de arriba llega como 500. Es reintentable con backoff exponencial, igual que cualquier 5xx. Ver Reglas transversales.


GET /{tenantSlug}/canales/whatsapp/{channelId}/mensajes/{id}

El {id} es el de la fila del registro —el que devolvió el despacho—, no el wamid.

Ventana de terminal
curl "https://api.redcumbre.cl/{tenantSlug}/canales/whatsapp/{channelId}/mensajes/cmtugt4ln0003se8nqas72beu" \
-H "Authorization: Bearer {api_key}"
{
"success": true,
"data": {
"id": "cmtugt4ln0003se8nqas72beu",
"createdAt": "2026-09-09T19:01:56.400Z",
"updatedAt": "2026-09-09T19:01:56.931Z",
"direccion": "saliente",
"procedencia": "api",
"canalId": "080a7de6-02e2-4682-8c0a-e27ba4a2d623",
"contraparte": "+56912345678",
"contraparteNormalizada": "56912345678",
"plantillaNombre": "documento_firmado_v1",
"plantillaIdioma": "es",
"plantillaComponentes": {
"cuerpo": { "variables": [{ "posicion": 1, "valor": "María" }, { "posicion": 2, "valor": "Contrato 4821" }] },
"boton": { "indice": "0", "variables": [{ "posicion": 1, "valor": "f/abc123XYZ" }] }
},
"estado": "delivered",
"wamid": "wamid.HBgLNTY5…",
"errorCodigo": null,
"errorMensaje": null,
"sandbox": false,
"cobrado": true,
"noCobradoMotivo": null,
"aceptadoEn": "2026-09-09T19:01:56.931Z",
"entregadoEn": "2026-09-09T19:01:59.000Z",
"leidoEn": "2026-09-09T19:03:12.000Z",
"tipoMensaje": null,
"cuerpo": null,
"entregaWebhookEstado": "no_aplica",
"entregaWebhookIntentos": 0,
"entregaWebhookEn": null
}
}

Lo que trae, y nada más: la plantilla, las variables que enviaste, el estado, el wamid, la causa exacta del error si lo hubo, las marcas de tiempo —incluidas la de entrega y la de lectura— y la marca de sandbox. Es lo que necesitas para responder un reclamo.

Los cinco campos del final describen la mitad entrante del registro, y en un saliente vienen como en el ejemplo:

CampoEn un salienteEn un entrante
entregadoEnEl acuse de entrega de WhatsApp, o nullnull
leidoEnEl acuse de lectura, o null. No cambia el estadonull
tipoMensajenull — un saliente es una plantillaEl tipo tal como lo nombra WhatsApp
cuerponullEl texto, sólo si el mensaje es de texto
entregaWebhookEstadono_aplica — no hay aviso que entregarentregado, entregado_sin_contenido, perdido o sin_webhook
entregaWebhookIntentos0Cuántas veces se intentó avisarle a tu sistema
entregaWebhookEnnullCuándo quedó cerrado el desenlace

Un mensaje de otro tenant, de otro canal, o que no existe, responde 404 — los tres indistinguibles, nunca 403.


Si la API key se creó en modo sandbox, el despacho:

  • no llama a Meta — no sale ningún mensaje;
  • no genera cobro;
  • sí escribe la fila del registro, marcada como sandbox.

La respuesta declara el modo, para que tu sistema pueda distinguir lo simulado de lo real sin cambiar de endpoint:

{
"success": true,
"data": {
"id": "cmtugtxaq0005se8n63n27jx3",
"wamid": "wamid.SANDBOX-cmtugtxaq0005se8n63n27jx3",
"estado": "submitted",
"sandbox": true,
"cobrado": false,
"noCobradoMotivo": "sandbox"
}
}

El wamid de sandbox lleva el prefijo wamid.SANDBOX-. Ver Entornos y Sandbox.


const BASE = 'https://api.redcumbre.cl';
async function despacharWhatsapp({ tenantSlug, channelId, apiKey, destinatario, template }) {
const response = await fetch(
`${BASE}/${tenantSlug}/canales/whatsapp/${channelId}/enviar`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
destinatario,
templateName: template.nombre,
languageCode: template.idioma,
componentes: template.componentes,
}),
}
);
const body = await response.json();
if (!response.ok) {
// `message` puede ser string o array de strings (validación).
// En 422 y 429, `error` trae el código y `message` la causa real de Meta.
throw new Error(`${body.error ?? response.status}: ${body.message}`);
}
const { id, wamid, estado } = body.data;
// Guarda `id`: es con lo que consultas después. El `estado` de la respuesta es "submitted" y
// avanza solo con los acuses de WhatsApp — consultalo, no lo supongas.
return { id, wamid, estado };
}

¿Puedo mandar texto libre en vez de una plantilla? No. Este endpoint despacha plantillas aprobadas, que es lo que Meta permite para iniciar una conversación con alguien que no te escribió primero.

¿Cómo sé si el mensaje llegó? Por el estado del mensaje: cuando WhatsApp acusa la entrega, pasa a delivered. Y si el destinatario lo leyó, el campo leidoEn se llena — sin cambiar el estado. Los acuses llegan en segundos, pero desordenados: consultá el estado, no supongas el orden.

¿Puedo recibir las respuestas de mis destinatarios? Sí. Configurá el webhook de recepción del canal desde el panel: cada mensaje que te escriban se te entrega firmado, con reintentos. Ojo con una cosa: un mensaje que no es texto llega sin su contenido — recibís el tipo, quién escribió y cuándo, y el archivo queda en WhatsApp. Se cobra igual. Está explicado arriba y en la sección del webhook.

¿La API key sirve para varios canales? Sí. La llave no está atada a ningún canal: el canal va en la ruta, y en cada llamada se comprueba que sea de tu tenant.

¿Puedo crear plantillas por API con esta llave? No. El rol WHATSAPP_MENSAJERIA_DESPACHO no alcanza la administración de plantillas. Se crean desde el panel.