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

# Template Messages

> Send a WhatsApp message using an approved template, and check where it got to.

# Template Messages

When you need to write to a customer first — recovering an abandoned cart, telling someone
their order is ready, delivering a pickup PIN — the message has to go out as a
**Meta-approved template**. These endpoints are the public door for requesting that send
and for checking where it got to.

<Warning>
  **Your account has to be enabled before your first call.** The contract is published
  ahead of general availability so you can build against it; enablement is per account and
  we arrange it with you along with the template and the sender number.

  **Until your account is enabled, every call answers `422` with
  `"type": "account_not_eligible"` — always, not intermittently.** It is not a problem with
  your request and no retry changes it: it is configuration on our side, and the response
  says so in those words instead of hiding it behind a generic error. Write to us to get it
  enabled. Any change to the contract is announced in the [Changelog](/en/changelog).
</Warning>

## What it does and what it does not

| It does | It does not |
| - | - |
| Validate the phone, the template and its variables | **It does not create or update the contact**, and no public call does today: the Contacts API is read-only (`GET /v1/contacts`). If having the person on record is part of your flow, talk to us before integrating |
| Discard a stale event instead of sending it late | Decide when a cart counts as abandoned: that logic is yours |
| Stay idempotent on your `event_id` | Open a free-form conversation. Outbound is always a template; the conversation starts if the customer replies — Meta's rule, not ours |
| Return a `conversation_id` you can quote to us | **Show the sent template in `GET /v1/conversations/{id}/messages`.** The send and that message list are served from different stores today, so reconcile against your own `event_id`, not against the message list |

## Prerequisites

| Requirement | Detail |
| - | - |
| **Meta-approved template** | It must exist as an approved template in the same WhatsApp portfolio (WABA) as your sender number. If it is approved in a different portfolio it cannot be sent from that number: it has to be replicated, which reopens Meta's approval window. |
| **Active sender number** | A WhatsApp Business number connected to YaVendio. Without one, every send answers `422` with `"type": "no_sender_number"`. |
| **API key with the scopes** | `outbound:send` to send, `outbound:read` to check status — see [Authentication](/en/authentication). |
| **Consent basis** | Your checkout's acceptance text and the date it took effect. |

<Note>
  **To find out which templates you can send, read the catalogue.**
  `GET /v1/templates` lists the ones
  you have approved, with the exact name and language this call expects. An empty list
  means none is approved yet, and until one is every send is rejected.
  Each template's `sendable` field says whether this API can send it: it is `false` when the text mixes positional and named variables, numbers them with gaps, or carries a variable in the header (this API fills the body only). A template with `sendable: false` answers `422` on send, and the fix lives in Meta, not in your request.
</Note>

## Send a message

```bash theme={null}
curl -X POST https://api.ya.onl/v1/template-messages \
  -H "X-API-Key: yv_live_v1_your_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://your-store.com/cart/8F3A21"
  },
  "occurred_at": "2026-08-11T13:22:05Z",
  "metadata": { "use_case": "cart_abandonment" }
}
JSON
```

### Request fields

| Field | Required | Format | Description |
| - | - | - | - |
| `event_id` | Yes | `^[A-Za-z0-9._-]{8,128}$` | **Your** identifier for the event. It is the idempotency key and the lookup key for the status endpoint. It must be stable across retries — never generate it per attempt. The charset is bounded because this value travels in the status URL. |
| `phone` | Yes | Strict E.164: `^\+[1-9]\d{7,14}$` | Destination phone. A number outside the format is rejected with `422` — **we never complete the country code for you**, because a guessed code can produce another real person's number. |
| `template.name` | Yes | text, 1–512 | Template name **exactly** as approved in Meta. |
| `template.language` | Yes | text, 2–15 | Template language code. `es` and `es_PE` are **not the same template**. |
| `variables` | Per template | object `{"1": "…", "2": "…"}` or `{"name": "…"}` | Variable values, keyed exactly as `required_variables` lists them in `GET /v1/templates`: by position (`"1"` fills `{{1}}`) or by name (`"name"` fills `{{name}}`), depending on how the template was authored. A missing or unknown key is a `422`. |
| `occurred_at` | No | ISO 8601 with offset | When the originating event happened. Drives freshness — see Time windows. **A value with no offset is read as UTC**, so send `Z` or your explicit offset. Omit it and the event is never considered stale. |
| `metadata.use_case` | No | text, ≤120 | Free-form label attributing the send to a use case, e.g. `cart_abandonment`. It does not affect the send. |

<Note>
  **Your company identifier does not travel in the request:** it is derived from your API
  key ([Authentication](/en/authentication)). The template does travel, by name and
  language, and is validated against the ones you have approved.
</Note>

## Responses

### Send accepted — `202`

We validate, reserve the `event_id` and hand it to the sending service. The response
arrives as soon as that happens and **does not carry the outcome**:

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

<Warning>
  **`accepted` means "on its way", not "it worked".** The send carries on after the
  response. **Do not read it as success** — check the status endpoint, where it resolves
  against the real message. It is the only route: the webhooks at `/v1/webhooks` emit
  order events only (`order.created`, `order.status_changed`), none for delivery.
</Warning>

<Warning>
  **Set your client timeout to 35 seconds or more.** The `POST` awaits the call to the
  sending service, which has its own 30-second ceiling. On the normal path it answers in
  under a second, but that is the typical case and not a guarantee: a client with a 5 or
  10-second timeout
  abandons healthy requests that are still in flight, and then reports failures that never
  happened.
</Warning>

### No new send — `200`

A `200` means this request **produced no send**. Two cases:

* **A repeated `event_id`**: it returns the stored state of the first request
  ([Idempotency](#idempotency)).
* **A send that is unviable from the start** — the event is stale, the customer's
  conversation is paused, or the merchant switched sending off from the panel: it answers
  `"status": "discarded"` with the reason in `status_detail` (`stale`, `paused` or
  `disabled`). The request worked; the send is off by design.

## Idempotency

**Same `event_id` = same send.** Repeating it returns the state of the first request and
**never produces a second WhatsApp**. That is why retrying on a network timeout is safe.

Idempotency runs **before the phone, template and freshness checks**: a replay returns the
stored state without judging those fields again. What does run first is the **shape** of
the body — the `event_id` charset, the `template.name` and `template.language` lengths,
`occurred_at` parsing. A malformed replay gets a `422` and never reaches idempotency.

There is one case where an `event_id` stops working: if the sending side has already bound
it to a different message, or it aged out of the 30-day window, the answer is `409` with
`"type": "idempotency_conflict"`. Send that event under a new `event_id` — repeating the
same one returns the same `409`. We deliberately store no state under the refused key: if
we did, the next attempt would get a quiet replay instead of the `409` that tells you what
to fix.

| Retry | Do not retry |
| - | - |
| `429`, `500` and network timeouts — use exponential backoff ([Rate Limiting](/en/rate-limiting)) | `401`, `403` and the data `422`s — these are data or credential errors; retrying repeats the error and burns your quota |
| `502`, but **cap the attempts** — see the note under Errors | `422` with `"type": "account_not_eligible"` or `"type": "no_sender_number"` — account configuration, not the body; no retry changes them |
| `409` (`idempotency_conflict`), but **under a new `event_id`** — the same one collides again | `409` under the same `event_id` — the answer will be identical |
| `402`, but **after adding funds** — under the same `event_id`, which is still free | `402` without adding funds — the answer will be identical |

<Warning>
  **A `422` on a replay deserves a look before you drop the event.** If the first attempt
  may already have gone out, the event is not lost: fix the shape of the body and resend
  with the **same** `event_id`, which stays safe and returns the real state. When the `422`
  was an account refusal, that resend answers `200` with `"status": "failed"` — it is the
  stored state of the first attempt, not a new send.
</Warning>

<Warning>
  **The `event_id` is not a trace identifier.** If your system generates a new id per HTTP
  attempt, that value is not usable as an `event_id`: each retry would look like a distinct
  event and **the customer would receive the WhatsApp more than once**. To trace attempts
  use the `X-Request-ID` header — see [Request IDs](/en/request-ids).
</Warning>

## Check the status

"Sent" is not "delivered": Meta reports delivery to the phone minutes or hours later — or
never, if the phone is off. That cycle is checked by your `event_id`:

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

```json theme={null}
{
  "event_id": "CART-8F3A21",
  "status": "accepted",
  "status_detail": null,
  "updated_at": "2026-08-11T13:24:40Z"
}
```

An `event_id` that was never sent answers `404`; one that does not match the charset
answers `422`.

| Status | Means | Reachable today? |
| - | - | - |
| `accepted` | Received and handed to the sending service. This is what the `202` returns. | Yes |
| `in_progress` | **Legacy.** No new send reaches this status; only sends made before this change that are still inside the retention window return it. Treat it like `accepted`. | Earlier sends only |
| `discarded` | Dropped by rule **before** sending. `status_detail` carries the reason: `stale` (the event expired), `paused` (the conversation is on hold) or `disabled` (the merchant switched sending off from the panel). | Yes |
| `sent` | Meta accepted the message. | Yes |
| `delivered` | Delivered to the customer's phone. | Yes |
| `read` | Read by the customer. | Yes |
| `failed` | Definitive rejection. `status_detail` carries the reason — see below. | Yes |

<Warning>
  **`status_detail` on a `failed` is not a closed set, and may be absent.** It is either an
  account refusal (`account_not_eligible`, `no_sender_number`), a send that did not complete
  (`send_failed`) or was cancelled before it was attempted (`send_cancelled`), or, when the
  provider refuses delivery with a code,
  `provider_error_<code>` — for example `provider_error_131049`. The code is Meta's and
  their catalogue changes without telling us, so **do not switch exhaustively on this
  field**: branch on `status`, log `status_detail`, and treat any unknown or missing value
  as a generic failure.

  An `accepted` may also carry `delivery_not_observable`: that merchant's sends live in an
  instance of their own that this deployment cannot read, so the status will not resolve
  on each poll — it is not a failure.
</Warning>

<Note>
  Two details worth knowing when you integrate:

  * **`updated_at` is when the record last changed**, not when you asked. Polling does not
    move it, but a delivery write that leaves `status` unchanged does, so **do not use it
    as a watermark**: reconcile on `event_id`.
  * **Only `failed` and `discarded` are final.** `sent`, `delivered` and `read` are re-resolved on every
    check, because a later receipt can still arrive — including a failure after a `read`.
</Note>

## Time windows

| Rule | Value | Detail |
| - | - | - |
| **Event freshness** | **6 hours** from `occurred_at` | An event older than the window is discarded (`discarded`, reason `stale`) rather than sent. A recovery message that arrives the next day does not recover anything, it only annoys. Omit `occurred_at` and this rule does not apply. |
| **Idempotency retention** | **30 days** | The window during which a repeated `event_id` is recognised as a duplicate and its status stays queryable. |
| **Rate limit** | See [Rate Limiting](/en/rate-limiting) | Shared with the rest of the API, not reserved for these endpoints, and every response carries the live value in the `X-RateLimit-Limit` header. Worth knowing when sizing a batch, since each `POST` can occupy up to 30 seconds. |

<Warning>
  **Send the offset in `occurred_at`.** A value with no timezone is read as UTC. If your
  system sends Peru local time (UTC-5), the event is aged an extra 5 hours against a 6-hour
  window: a cart abandoned two hours ago computes as seven hours old and comes back
  `discarded` with reason `stale` — answered `200`, no error, and no message sent.
</Warning>

## Errors

Errors follow the [standard API format](/en/errors):

| Status | When |
| - | - |
| `401` | API key missing, invalid, revoked or expired. |
| `402` | *(POST only)* The account cannot pay for the message and nothing was sent. `"type"` says which: `insufficient_funds` (no funds left), `wallet_sends_paused` (sending stopped when the funds ran out) or `wallet_currency_mismatch`. The first two clear as soon as the account adds funds from the WhatsApp payments page, and sending resumes on its own; the third does not and needs us. |
| `403` | The key lacks the required scope: `outbound:send` to send, `outbound:read` to read. |
| `404` | *(GET only)* No message with that `event_id`. |
| `409` | *(POST only)* The `event_id` is already bound to a different message, or it aged out of the idempotency window. `"type": "idempotency_conflict"`. Send the event under a new `event_id`. |
| `422` | Invalid data: phone outside E.164, a template not approved for your account in that language, or a required variable missing. The message names the field. |
| `422` | *(POST only)* Account refusal: `"type": "account_not_eligible"` if your account is not enabled to send, `"type": "no_sender_number"` if it has no connected WhatsApp number to send from. Neither is fixable from the request body. |
| `422` | *(on the GET)* The `event_id` in the URL does not match the charset. |
| `429` | Rate limit exceeded — see [Rate Limiting](/en/rate-limiting). |
| `500` | Unexpected failure on our side. Retry with backoff — it is safe by `event_id`. |
| `502` | *(POST only)* The internal sending service failed or is unavailable. Read the note below before retrying. |

<Note>
  **The two account `422`s are told apart by `detail[0].type`**, not by a field: they carry
  `"loc": ["body"]` because there is no value to correct. It is the same array format as
  any other `422` — see [Error Handling](/en/errors). The `409`, by contrast, uses the
  standard format and carries its `type` at the root.
</Note>

<Warning>
  **A `502` no longer means "it could be anything".** The refusals that depend on your
  account or your event have their own answer: an account not enabled to send and a missing
  sender number are `422`s, a paused conversation is a `200` with `"status": "discarded"`
  and `"status_detail": "paused"`, and a recycled key is a `409`. What is left in `502` is a
  real failure of the internal sending service. Retry with backoff a few times; if it
  persists, stop retrying and contact us.
</Warning>

<Note>
  Neither a stale event, nor a paused conversation, nor sending switched off is an error:
  they all answer `200` with `"status": "discarded"` and `"status_detail"` set to `"stale"`,
  `"paused"` or `"disabled"`. A pause is put there by a human agent who took over the
  conversation, or by a billing freeze; the switch is thrown by the merchant from the cart
  abandonment panel. Neither is lifted from the request.
</Note>

## Recipe: Shopify (Flow)

Shopify Flow makes this same `POST` with no code and no server of your own in the middle:
it is the guided example of the contract above, with the abandonment trigger already solved.

On Shopify? Follow the [guided recipe →](/en/outbound/shopify-flow)

## Testing the integration

<Warning>
  **There is no sandbox environment.** The dashboard always creates `live` keys and there is
  no isolated environment behind them — see [Authentication](/en/authentication). This
  integration's test environment is distinguished by the **destination phone**, not by the
  credential.
</Warning>

* **Test phase** — send to your own team's phones, in an agreed window. The message really
  arrives, with the real template: that is exactly the test.
* **Production phase** — same key, same contract; the only change is that the phones belong
  to real customers.

The list of destination phones is therefore the only real control between one phase and the
other: agree it in writing before the first test and treat it as part of the integration's
configuration.


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