> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ya.works/llms.txt
> Use this file to discover all available pages before exploring further.

# Mensajes de Plantilla

> Envía un WhatsApp con una plantilla aprobada y consulta en qué estado quedó.

# Mensajes de Plantilla

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

<Warning>
  **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](/changelog).
</Warning>

## 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](/authentication). |
| **Base de consentimiento** | El texto de aceptación de tu checkout y su fecha de vigencia. |

<Note>
  **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.
</Note>

## Enviar un mensaje

```bash theme={null}
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. |

<Note>
  **El identificador de tu empresa no viaja en el request:** se deriva de tu API key
  ([Autenticación](/authentication)). La plantilla sí viaja, por nombre e idioma, y se
  valida contra las que tienes aprobadas.
</Note>

## 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**:

```json theme={null}
{
  "event_id": "CART-8F3A21",
  "status": "accepted",
  "status_detail": null
}
```

<Warning>
  **`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.
</Warning>

<Warning>
  **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.
</Warning>

### 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](#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](/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 |

<Warning>
  **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.
</Warning>

<Warning>
  **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](/request-ids).
</Warning>

## 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`:

```bash theme={null}
curl https://api.ya.onl/v1/template-messages/CART-8F3A21 \
  -H "X-API-Key: yv_live_v1_tu_api_key"
```

```json theme={null}
{
  "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í |

<Warning>
  **`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.
</Warning>

<Note>
  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`.
</Note>

## 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](/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. |

<Warning>
  **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.
</Warning>

## Errores

Los errores siguen el [formato estándar de la API](/errors):

| 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](/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. |

<Note>
  **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](/errors). El `409`, en cambio, usa el
  formato estándar y trae su `type` en la raíz.
</Note>

<Warning>
  **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.
</Warning>

<Note>
  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.
</Note>

## 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 →](/outbound/shopify-flow)

## Probar la integración

<Warning>
  **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](/authentication). El ambiente de
  prueba de esta integración se diferencia por el **teléfono destino**, no por la
  credencial.
</Warning>

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.