API and developers

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

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 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) 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_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.

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.

Need help?