API y desarrolladores

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" }
}
JSON

Campos 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_id repetido: 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 en status_detail (stale, paused o disabled). 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_at es cuándo cambió por última vez el registro, no cuándo consultaste. Consultar no lo mueve, pero una escritura de entrega que deja el status igual sí lo mueve, así que no lo uses como marca de agua: reconcilia por event_id.
  • Sólo failed y discarded son definitivos. sent, delivered y read se vuelven a resolver en cada consulta, porque todavía puede llegar un recibo posterior — incluso uno de fallo después de un read.

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.

¿Necesitas ayuda?