Template Messages
Send a WhatsApp message using an approved template, and check where it got to.
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.
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.
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. |
| Consent basis | Your checkout's acceptance text and the date it took effect. |
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.
Send a message
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" }
}
JSONRequest 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. |
Your company identifier does not travel in the request: it is derived from your API key (Authentication). The template does travel, by name and language, and is validated against the ones you have approved.
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:
{
"event_id": "CART-8F3A21",
"status": "accepted",
"status_detail": null
}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.
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.
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). - 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 instatus_detail(stale,pausedordisabled). 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) |
401, 403 and the data 422s — 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 |
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.
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.
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:
curl https://api.ya.onl/v1/template-messages/CART-8F3A21 \
-H "X-API-Key: yv_live_v1_your_api_key"{
"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 |
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.
Two details worth knowing when you integrate:
updated_atis when the record last changed, not when you asked. Polling does not move it, but a delivery write that leavesstatusunchanged does, so do not use it as a watermark: reconcile onevent_id.- Only
failedanddiscardedare final.sent,deliveredandreadare re-resolved on every check, because a later receipt can still arrive — including a failure after aread.
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 | 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. |
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.
Errors
Errors follow the standard API format:
| 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. |
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. |
The two account 422s 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. The 409, by contrast, uses the
standard format and carries its type at the root.
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 422s, 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.
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.
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 →
Testing the integration
There is no sandbox environment. The dashboard always creates live keys and there is
no isolated environment behind them — see Authentication. This
integration's test environment is distinguished by the destination phone, not by the
credential.
- 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.
