Mensajes de Plantilla
Envía un WhatsApp con una plantilla aprobada y consulta en qué estado quedó.
Cuando necesitas escribirle primero a un cliente — recuperar un carrito abandonado, avisar que un pedido está listo, entregar un PIN de retiro — el mensaje tiene que salir como una plantilla aprobada por Meta. Estos endpoints son la puerta pública para pedir ese envío y para consultar dónde quedó.
Tu cuenta tiene que estar habilitada antes de la primera llamada. El contrato se publica por adelantado para que puedas construir; la habilitación es por cuenta y la coordinamos contigo junto con la plantilla y el número remitente.
Mientras tu cuenta no esté habilitada, cada llamada responde 422 con
"type": "account_not_eligible", siempre y no de forma intermitente. No es un problema
de tu request y ningún reintento lo cambia: es configuración de nuestro lado, y la
respuesta lo dice con esas palabras en vez de esconderlo detrás de un error genérico.
Escríbenos para habilitarla. Cualquier cambio al contrato se anuncia en el
Changelog.
Qué hace y qué no hace
| Hace | No hace |
|---|---|
| Valida el teléfono, la plantilla y sus variables | No crea ni actualiza el contacto. Y hoy no hay ninguna llamada pública que lo haga: la API de Contactos es de solo lectura (GET /v1/contacts). Si el registro de la persona es parte de tu flujo, conversémoslo antes de integrar |
| Descarta un aviso vencido en vez de enviarlo tarde | No define cuándo un carrito está abandonado: esa lógica es tuya |
Es idempotente por tu event_id |
No abre conversación libre. El outbound es siempre plantilla; la conversación empieza si el cliente responde — regla de Meta, no nuestra |
Devuelve un conversation_id que podés citarnos |
No muestra la plantilla enviada en GET /v1/conversations/{id}/messages. Hoy el envío y esa lista de mensajes se sirven desde almacenes distintos: reconciliá contra tu propio event_id, no contra la lista |
Requisitos previos
| Requisito | Detalle |
|---|---|
| Plantilla aprobada en Meta | Debe existir como plantilla aprobada en el mismo portafolio de WhatsApp (WABA) que tu número remitente. Si está aprobada en otro portafolio, no se puede enviar desde ese número: hay que replicarla, y eso reabre la ventana de aprobación de Meta. |
| Número remitente activo | Un número de WhatsApp Business conectado a YaVendio. Sin él, cada envío responde 422 con "type": "no_sender_number". |
| API key con los alcances | outbound:send para enviar, outbound:read para consultar el estado — ver Autenticación. |
| Base de consentimiento | El texto de aceptación de tu checkout y su fecha de vigencia. |
Para saber qué plantillas puedes enviar, consulta el catálogo.
GET /v1/templates lista las que
tienes aprobadas, con el nombre y el idioma exactos que van en esta llamada. Una lista
vacía significa que todavía no hay ninguna aprobada, y hasta que la haya cada envío
se rechaza.
El campo sendable de cada plantilla dice si esta API puede enviarla: es false cuando el texto mezcla variables por posición y por nombre, las numera con huecos o lleva una variable en el encabezado (esta API rellena solo el cuerpo). Una plantilla con sendable: false responde 422 al enviar, y el arreglo está en Meta, no en tu request.
Enviar un mensaje
curl -X POST https://api.ya.onl/v1/template-messages \
-H "X-API-Key: yv_live_v1_tu_api_key" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{
"event_id": "CART-8F3A21",
"phone": "+51987654321",
"template": { "name": "cart_recovery", "language": "es" },
"variables": {
"1": "Juan",
"2": "S/ 1.250",
"3": "https://tu-tienda.com/cart/8F3A21"
},
"occurred_at": "2026-08-11T13:22:05Z",
"metadata": { "use_case": "cart_abandonment" }
}
JSONCampos del request
| Campo | Obligatorio | Formato | Descripción |
|---|---|---|---|
event_id |
Sí | ^[A-Za-z0-9._-]{8,128}$ |
Tu identificador del evento. Es la clave de idempotencia y la llave para consultar el estado. Debe ser estable entre reintentos — nunca lo generes por intento. El charset está acotado porque este valor viaja en la URL del GET de estado. |
phone |
Sí | E.164 estricto: ^\+[1-9]\d{7,14}$ |
Teléfono destino. Un número fuera de formato se rechaza con 422 — nunca completamos el código de país por ti, porque un código adivinado puede producir el número válido de otra persona. |
template.name |
Sí | texto, 1–512 | Nombre de la plantilla exactamente como está aprobada en Meta. |
template.language |
Sí | texto, 2–15 | Código de idioma de la plantilla. es y es_PE no son la misma plantilla. |
variables |
Según la plantilla | objeto {"1": "…", "2": "…"} o {"name": "…"} |
Valores de las variables, con exactamente las claves que required_variables publica en GET /v1/templates: por posición ("1" rellena {{1}}) o por nombre ("name" rellena {{name}}), según cómo se creó la plantilla. Una clave que falta o que sobra es un 422. |
occurred_at |
No | ISO 8601 con offset | Cuándo ocurrió el evento que origina el mensaje. Controla la frescura — ver Ventanas de tiempo. Un valor sin offset se lee como UTC, así que manda Z o tu offset explícito. Si lo omites, el aviso nunca se considera vencido. |
metadata.use_case |
No | texto, ≤120 | Etiqueta libre para atribuir el envío a un caso de uso, por ejemplo cart_abandonment. No afecta el envío. |
El identificador de tu empresa no viaja en el request: se deriva de tu API key (Autenticación). La plantilla sí viaja, por nombre e idioma, y se valida contra las que tienes aprobadas.
Respuestas
Envío aceptado — 202
Validamos, reservamos el event_id y lo entregamos al servicio de envío. La respuesta
llega apenas eso ocurre y no trae el desenlace:
{
"event_id": "CART-8F3A21",
"status": "accepted",
"status_detail": null
}accepted significa "va en camino", no "salió bien". El envío sigue su curso después
de la respuesta. No lo leas como éxito — consulta el estado, que ahí sí se resuelve
contra el mensaje real. Es el único camino: los webhooks de /v1/webhooks sólo emiten
eventos de pedido (order.created, order.status_changed), ninguno de entrega.
Configura el timeout de tu cliente en 35 segundos o más. El POST espera la llamada
al servicio de envío, que tiene su propio tope de 30 segundos. En el camino normal
responde en menos de un segundo, pero eso es el caso típico y no una garantía: un
cliente con timeout de 5 o 10 segundos abandona requests sanos que siguen en vuelo, y
después ve fallas que nunca ocurrieron.
Sin envío nuevo — 200
Un 200 significa que esta petición no produjo un envío. Son dos casos:
- Un
event_idrepetido: devuelve el estado guardado de la primera petición (Idempotencia). - Un envío inviable de entrada — el aviso está vencido, la conversación del cliente está
en pausa, o el comercio apagó el envío desde el panel: responde
"status": "discarded"con el motivo enstatus_detail(stale,pausedodisabled). La petición funcionó; el envío está apagado a propósito.
Idempotencia
Mismo event_id = mismo envío. Reenviarlo devuelve el estado de la primera petición y
nunca genera un segundo WhatsApp. Por eso es seguro reintentar ante un timeout de red.
La idempotencia corre antes de las validaciones de teléfono, plantilla y frescura: un
reenvío devuelve el estado guardado sin volver a juzgar esos datos. Lo que sí se evalúa
primero es la forma del cuerpo — el charset del event_id, los largos de
template.name y template.language, que occurred_at parsee. Un reenvío mal formado
recibe 422 y nunca llega a la idempotencia.
Hay un caso en que un event_id deja de servir: si del lado del envío ya quedó atado a otro
mensaje, o salió de la ventana de 30 días, la respuesta es 409 con
"type": "idempotency_conflict". Manda ese evento con un event_id nuevo — repetir el
mismo devuelve el mismo 409. A propósito no guardamos estado bajo la clave rechazada: si
lo hiciéramos, el siguiente intento recibiría un reenvío silencioso en vez del 409 que te
dice qué corregir.
| Reintentar | No reintentar |
|---|---|
429, 500 y timeouts de red — usa backoff exponencial (Rate Limiting) |
401, 403 y los 422 de dato — son errores del dato o de la credencial; el reintento repite el error y consume tu cuota |
502, pero acotando los intentos — ver la nota en Errores |
422 con "type": "account_not_eligible" o "type": "no_sender_number" — son de configuración de cuenta, no del cuerpo; ningún reintento los cambia |
409 (idempotency_conflict), pero con un event_id nuevo — el mismo vuelve a chocar |
409 con el mismo event_id — la respuesta va a ser idéntica |
402, pero después de recargar — con el mismo event_id, que sigue libre |
402 sin recargar — la respuesta va a ser idéntica |
Un 422 en un reenvío merece una mirada antes de descartar el evento. Si el primer
intento pudo haber salido, el evento no está perdido: corrige la forma del cuerpo y
reenvía con el mismo event_id, que sigue siendo seguro y devuelve el estado real.
Cuando el 422 fue de cuenta, ese reenvío responde 200 con "status": "failed" — es el
estado guardado del primer intento, no un envío nuevo.
El event_id no es un identificador de traza. Si tu sistema genera un id nuevo por
cada intento HTTP, ese valor no sirve como event_id: cada reintento se vería como un
evento distinto y el cliente recibiría el WhatsApp más de una vez. Para rastrear
intentos usa el header X-Request-ID — ver Request IDs.
Consultar el estado
"Enviado" no es "entregado": Meta reporta la entrega al teléfono minutos u horas después —
o nunca, si el teléfono está apagado. Ese ciclo se consulta por tu event_id:
curl https://api.ya.onl/v1/template-messages/CART-8F3A21 \
-H "X-API-Key: yv_live_v1_tu_api_key"{
"event_id": "CART-8F3A21",
"status": "accepted",
"status_detail": null,
"updated_at": "2026-08-11T13:24:40Z"
}Un event_id que nunca se envió responde 404; uno que no respeta el charset responde
422.
| Estado | Significa | ¿Alcanzable hoy? |
|---|---|---|
accepted |
Recibido y entregado al servicio de envío. Es lo que devuelve el 202. |
Sí |
in_progress |
Heredado. Ningún envío nuevo llega a este estado; solo lo devuelven los envíos anteriores a este cambio que sigan dentro de la ventana de retención. Trátalo igual que accepted. |
Solo envíos previos |
discarded |
Descartado por regla antes de enviar. status_detail trae el motivo: stale (aviso vencido), paused (conversación en pausa) o disabled (el comercio apagó el envío desde el panel). |
Sí |
sent |
Meta aceptó el mensaje. | Sí |
delivered |
Entregado al teléfono del cliente. | Sí |
read |
Leído por el cliente. | Sí |
failed |
Rechazo definitivo. status_detail trae el motivo — ver abajo. |
Sí |
status_detail en un failed no es una lista cerrada y puede faltar. Puede ser un
rechazo de cuenta (account_not_eligible, no_sender_number), un envío que no llegó a
completarse (send_failed) o que se canceló antes de intentarse (send_cancelled), o,
cuando el proveedor rechaza la entrega con un código,
provider_error_<código> — por ejemplo provider_error_131049. El código es el de Meta y
su catálogo cambia sin que nos avisen, así que no ramifiques de forma exhaustiva sobre
este campo: decide por status, registra status_detail y trata cualquier valor
desconocido —o ausente— como un fallo genérico.
Un accepted también puede traer delivery_not_observable: el envío de ese comercio
vive en una instancia propia que este despliegue no puede leer, así que el estado no se
resolverá en cada consulta — no es un fallo.
Dos detalles útiles al integrar:
updated_ates cuándo cambió por última vez el registro, no cuándo consultaste. Consultar no lo mueve, pero una escritura de entrega que deja elstatusigual sí lo mueve, así que no lo uses como marca de agua: reconcilia porevent_id.- Sólo
failedydiscardedson definitivos.sent,deliveredyreadse vuelven a resolver en cada consulta, porque todavía puede llegar un recibo posterior — incluso uno de fallo después de unread.
Ventanas de tiempo
| Regla | Valor | Detalle |
|---|---|---|
| Frescura del aviso | 6 horas desde occurred_at |
Un evento más viejo que la ventana se descarta (discarded, motivo stale) en vez de enviarse. Un mensaje de recuperación que llega al día siguiente ya no recupera, solo molesta. Si omites occurred_at, esta regla no aplica. |
| Retención de idempotencia | 30 días | Ventana durante la cual un event_id repetido se reconoce como duplicado y su estado sigue consultable. |
| Límite de peticiones | Ver Rate Limiting | Es compartido con el resto de la API, no exclusivo de estos endpoints, y cada respuesta trae el valor vigente en el header X-RateLimit-Limit. Tenlo en cuenta al dimensionar un lote, porque cada POST puede ocupar hasta 30 segundos. |
Manda el offset en occurred_at. Un valor sin zona horaria se interpreta como UTC. Si
tu sistema envía hora local de Perú (UTC-5), el evento se envejece 5 horas de más contra
una ventana de 6: un carrito abandonado hace dos horas computa como de siete y sale
discarded por stale — con respuesta 200, sin error y sin mensaje enviado.
Errores
Los errores siguen el formato estándar de la API:
| Status | Cuándo |
|---|---|
401 |
API key ausente, inválida, revocada o expirada. |
402 |
(solo en el POST) La cuenta no puede pagar el mensaje y no se envió nada. "type" dice cuál: insufficient_funds (sin fondos), wallet_sends_paused (los envíos se cortaron cuando se acabaron los fondos) o wallet_currency_mismatch. Los dos primeros se resuelven recargando desde la página de pagos de WhatsApp, y el envío se reanuda solo; el tercero no y hay que escribirnos. |
403 |
La key no tiene el alcance requerido: outbound:send para enviar, outbound:read para consultar. |
404 |
(solo en el GET) No existe un mensaje con ese event_id. |
409 |
(solo en el POST) El event_id ya está atado a otro mensaje, o salió de la ventana de idempotencia. "type": "idempotency_conflict". Manda el evento con un event_id nuevo. |
422 |
Dato inválido: teléfono fuera de E.164, plantilla no aprobada para tu cuenta en ese idioma, o una variable obligatoria faltante. El mensaje nombra el campo. |
422 |
(solo en el POST) Rechazo de cuenta: "type": "account_not_eligible" si tu cuenta no está habilitada para enviar, "type": "no_sender_number" si no tiene un número de WhatsApp conectado desde el cual enviar. Ninguno se corrige desde el cuerpo del request. |
422 |
(en el GET) El event_id de la URL no respeta el charset. |
429 |
Límite de peticiones excedido — ver Rate Limiting. |
500 |
Falla inesperada de nuestro lado. Reintenta con backoff — es seguro por event_id. |
502 |
(solo en el POST) El servicio interno de envío falló o no está disponible. Lee la nota de abajo antes de reintentar. |
Los dos 422 de cuenta se distinguen por detail[0].type, no por el campo: llegan
con "loc": ["body"] porque no hay un valor que corregir. Es el mismo formato de array
que cualquier otro 422 — ver Manejo de Errores. El 409, en cambio, usa el
formato estándar y trae su type en la raíz.
Un 502 ya no significa "puede ser cualquier cosa". Los rechazos que dependen de tu
cuenta o de tu evento tienen su propia respuesta: cuenta no habilitada y falta de número
remitente son 422, una conversación en pausa es 200 con "status": "discarded" y
"status_detail": "paused", y una clave repetida es 409. Lo que queda en 502 es una
falla real del servicio interno de envío. Reintenta con backoff unas pocas veces; si
persiste, deja de reintentar y escríbenos.
Ni un aviso vencido, ni una conversación en pausa, ni el envío apagado son errores:
responden 200 con "status": "discarded" y "status_detail" en "stale", "paused" o
"disabled". Una pausa la pone un agente humano que tomó la conversación, o un corte por
facturación; el apagado lo pone el comercio desde el panel de carrito abandonado. Ninguno
de los dos se levanta desde el request.
Receta: Shopify (Flow)
Shopify Flow hace este mismo POST sin código y sin un servidor propio en medio: es el
ejemplo guiado del contrato de arriba, con el disparo del abandono ya resuelto.
¿Usas Shopify? Sigue la receta guiada →
Probar la integración
No existe un ambiente sandbox. El dashboard crea siempre keys de tipo live y no hay
un entorno aislado detrás de ellas — ver Autenticación. El ambiente de
prueba de esta integración se diferencia por el teléfono destino, no por la
credencial.
- Fase de prueba — envía a teléfonos de tu propio equipo, en una ventana acordada. El mensaje llega de verdad, con la plantilla real: esa es exactamente la prueba.
- Fase productiva — misma key, mismo contrato; solo cambia que los teléfonos son de clientes reales.
La lista de teléfonos destino es entonces el único control real entre una fase y la otra: acuérdala por escrito antes de la primera prueba y trátala como parte de la configuración de la integración.
