# Send a WhatsApp template

> Send an approved WhatsApp template with POST /v1/messages, fill its variables, header and buttons, and track sent, delivered and read.

import { Tabs, TabItem } from '@astrojs/starlight/components';

Send one approved WhatsApp template to one person, filled in for them, and
see when it is delivered and read.

**You need:** a WhatsApp number connected to the workspace, a template Meta
has approved on it, and credits ([before your first message](#before-your-first-message)).
Every field: [reference](/platform-api/reference/messages/#whatsappmessagecreate).

## Recipe: send a template and read the reply

Run in order after `export OMNI_OCTO_API_URL=https://api.omniocto.com` and
`export OMNI_OCTO_API_KEY=sk-octo_...` (an owner or admin key, for the events read).

**1. Mark where the event log is now:**

```bash
CURSOR=$(curl -s "$OMNI_OCTO_API_URL/v1/events?wait=0" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" | jq -r .next_cursor)
```

**2. Find an approved template** and the number it sends from:

```bash
TPL=$(curl -s "$OMNI_OCTO_API_URL/v1/templates?status=approved&limit=1" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" | jq '.data[0]')
echo "$TPL" | jq '{id, name, from, variable_keys}'
```

```json
{ "id": "wt-0000000001", "name": "order_ready", "from": "+16285550100",
  "variable_keys": ["first_name", "order"] }
```

**3. Send it.** This fills each of its `variable_keys` with `test`; put your
real values there:

```bash
MSG=$(curl -s -X POST "$OMNI_OCTO_API_URL/v1/messages" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "$(echo "$TPL" | jq -c '{ channel: "whatsapp", from: .from, to: "+15555550100",
        template: { id: .id, variables: ([.variable_keys[] | { (.): "test" }] | add // {}) } }')" \
  | jq -r .id)
```

**4. Read its status** ([statuses](#status)):

```bash
curl -s "$OMNI_OCTO_API_URL/v1/messages/$MSG" -H "Authorization: Bearer $OMNI_OCTO_API_KEY" | jq '{status, error}'
```

**5. Wait for their reply**, a `message.received` event (call again with the new
`next_cursor` until it arrives):

```bash
curl -s "$OMNI_OCTO_API_URL/v1/events?wait=25&types=message.received&after=$CURSOR" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

What it holds: [Hear replies](#hear-replies). Answer in the thread within
[24 hours](#the-24-hour-window).

## Send one template

<Tabs syncKey="lang">
<TabItem label="curl">

```bash
curl -X POST https://api.omniocto.com/v1/messages \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "channel": "whatsapp",
    "from": "+16285550100",
    "to": "+6591234567",
    "template": {
      "id": "wt-0000000001",
      "variables": { "first_name": "Mei", "order": "A-1042" }
    }
  }'
```

</TabItem>
<TabItem label="Node">

```js
const res = await fetch("https://api.omniocto.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OMNI_OCTO_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    channel: "whatsapp",
    from: "+16285550100",
    to: "+6591234567",
    template: { id: "wt-0000000001", variables: { first_name: "Mei", order: "A-1042" } },
  }),
});
const message = await res.json();
if (!res.ok) throw new Error(`${message.error.code}: ${message.error.message}`);
console.log(message.id, message.status);
```

</TabItem>
<TabItem label="Python">

```python
import os
import uuid
import requests

res = requests.post(
    "https://api.omniocto.com/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['OMNI_OCTO_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "channel": "whatsapp",
        "from": "+16285550100",
        "to": "+6591234567",
        "template": {"id": "wt-0000000001",
                     "variables": {"first_name": "Mei", "order": "A-1042"}},
    },
)
message = res.json()
if not res.ok:
    raise RuntimeError(f"{message['error']['code']}: {message['error']['message']}")
print(message["id"], message["status"])
```

</TabItem>
</Tabs>

```json
{ "id": "00000000-0000-4000-8000-000000000013", "object": "message", "channel": "whatsapp",
  "from": "+16285550100", "to": "+6591234567", "status": "sent",
  "conversation_id": "00000000-0000-4000-8000-000000000014", "campaign_id": null,
  "segments": null, "billed_credits": null, "error": null,
  "template": { "id": "wt-0000000001", "name": "order_ready", "language": "en_US" },
  "text": "Hi Mei, order A-1042 is ready.", "media": [],
  "created_at": "2026-10-15T17:02:11.000Z", "sent_at": null, "delivered_at": null,
  "read_at": null, "updated_at": null, "metadata": null }
```

The answer is `202 Accepted`: Meta has the message. Follow it with
`GET /v1/messages/{id}` or the `message.status_updated`
[webhook](/platform-api/events/), which tells you when it is delivered and read.

- `from` is your WhatsApp number, in any of the
  [`from` forms](/platform-api/overview/#ids).
- `to` is the recipient in E.164; anything else is `400 invalid_number`.
- `metadata` (optional): your own key/value pairs, with the same limits as
  on [a text](/platform-api/sms/#send-one-text).
- **Always send an `Idempotency-Key`.** A retry with it never sends twice
  ([how retries work](/platform-api/retries/#idempotency)).

## Before your first message

1. **A WhatsApp number connected** to the workspace, once, in the dashboard
   under Channels.
2. **A template Meta has approved** on that number. Create and submit
   templates in the dashboard; Meta reviews each one.
3. **Credits** ([Pricing](#pricing)).

`GET /v1/senders` shows each WhatsApp number with a `whatsapp` block:

```json
{ "id": "pn-0000000002", "object": "sender", "e164": "+16285550100", "label": "Front desk",
  "trial": false, "mode": "managed",
  "sms": { "ready": true, "status": "cleared", "fix": null, "provider": "twilio",
           "campaigns": { "ready": true, "status": "cleared", "fix": null } },
  "whatsapp": { "ready": true, "status": "ready", "fix": null, "channel_id": "ch-0000000002" },
  "voice": { "ready": true, "status": "ready", "fix": null },
  "channel_uuids": ["4e1d…", "8a07…", "c3f9…"] }
```

`whatsapp.ready` is `true` when the number can send; when it is `false`,
`fix` says what to do. A WhatsApp number that is not also a phone number of
the workspace is listed under its channel's `ch-…` id.

## Find a template

```bash
curl "https://api.omniocto.com/v1/templates?from=%2B16285550100" -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "object": "list", "has_more": false, "next_cursor": null,
  "data": [
    { "id": "wt-0000000001", "object": "whatsapp_template",
      "uuid": "00000000-0000-4000-8000-000000000001", "name": "order_ready",
      "language": "en_US", "category": "utility", "status": "approved", "from": "+16285550100",
      "body": "Hi {{first_name}}, order {{order}} is ready.", "header_text": null,
      "variables": ["first_name", "order"],
      "header": null, "buttons": [],
      "variable_keys": ["first_name", "order"], "all_buttons": [],
      "created_at": "2026-10-01T16:20:00.000Z" }
  ] }
```

Pass `id` as `template.id`, and send from the number in `from`. The rest
says what to fill: `variables` for the body, `header.needs` for a header,
and `needs` on each button (this template has neither, so the request at the
top fills it all). `variable_keys` are the keys a campaign fills
instead. Only `approved` templates send; `?status=all` lists the others.
[Every field](/platform-api/reference/lookups/#whatsapptemplate).

### Read one template

`GET /v1/templates/{id}` reads one template by its `wt-…` id or UUID,
whatever its `status`, so you can check why a send was refused. It answers
the same object as the list. Another workspace's id, or one that does not
exist, is `404 not_found`; another kind's id (`cl-…`) is
`400 invalid_request`.

```bash
curl https://api.omniocto.com/v1/templates/wt-0000000002 -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

`buttons` lists only the buttons a send fills. `all_buttons` lists every
button in the template, in order, with its address:

```json
"all_buttons": [
  { "index": 0, "type": "quick_reply", "text": "Stop" },
  { "index": 1, "type": "url", "text": "Track", "url": "https://shop.example/t/{{1}}",
    "dynamic": true, "example": "https://shop.example/t/A123" },
  { "index": 2, "type": "url", "text": "Home", "url": "https://shop.example",
    "dynamic": false, "example": null },
  { "index": 3, "type": "phone_number", "text": "Call us", "phone_number": "+15555550100" }
]
```

A `dynamic` URL button ends in `{{1}}`. You send only the part that replaces
it: `url_suffix` on a single send (`"buttons": [ { "index": 1, "url_suffix": "A123" } ]`
opens `https://shop.example/t/A123`), or the `button.1.1` key of
`variable_keys` in a campaign. A static URL button or a phone-number button
takes nothing.

### Name the template

Instead of the id, name it with `template.name` and `template.language`
(`"en_US"`), looked up on the `from` number. `language` may be left out when
the name exists in one language only; for a name in several languages,
leaving it out is `400 invalid_request` (`param: "template.language"`). A
name that finds nothing is
`404 template_not_found`, with the closest name as `hint`. Send the id or
the name and language: an `id` with a `name` or `language` that is not that
template's is `400 invalid_request` (`param: "template.name"` or
`"template.language"`), naming the template's own name and language.

## Fill the template

### Fill the variables

`template.variables` holds the body's values. A named template
(`{{first_name}}`) takes an object, `{ "first_name": "Mei" }`. A positional
template (`{{1}}`, `{{2}}`) takes an array in order, `["Mei", "A-1042"]`, or
an object keyed by position. Do not mix names and positions.

**A template with no variables** (`"variables": []` and
`"variable_keys": []` in `GET /v1/templates`) needs nothing filled. Leave
`template.variables` out; `{}` or `[]` reads the same:

```json
"template": { "name": "pickup_ready", "language": "en_US" }
```

Sending a value it has no slot for is `422 template_components_mismatch`,
and nothing goes out. A campaign with this template leaves `variables` out
too, or sends `{}`; any name in it is `400 unknown_variable`.

### Header and buttons

For a template with an image header and a dynamic URL button at index 1
(its address is in `all_buttons`; `GET /v1/templates` shows `"header": { "type": "image", "needs": "url" }`
and a button with `"needs": "url_suffix"`), fill all three:

```json
"template": {
  "id": "wt-0000000002",
  "variables": { "first_name": "Mei", "order": "A-1042" },
  "header": { "type": "image", "url": "https://example.com/promo.png" },
  "buttons": [ { "index": 1, "url_suffix": "A-1042" } ]
}
```

A `text` header takes `text`; an image, video or document header takes
`url`, an https link Meta can fetch. Each button names its `index` in the
template and one value: `url_suffix`, `coupon_code` or `payload` (a
quick-reply payload is optional).

<a id="fill-a-template"></a>

### Single send vs campaign keys

A single send fills each part with its own field; a campaign fills the same
parts through one flat [`variables`](/platform-api/reference/campaigns/#variables)
object, whose values are plain text or a `{{contact.<field>}}` reference.
`i` is the button's `index` in `GET /v1/templates`.

| template part | single send (`POST /v1/messages`) | campaign `variables` key |
|---|---|---|
| body, named (`{{first_name}}`) | `"variables": { "first_name": "Mei" }` | `"first_name": "{{contact.first_name}}"` |
| body, positional (`{{1}}`) | `"variables": ["Mei"]` or `{ "1": "Mei" }` | `"1": "{{contact.first_name}}"` |
| header text (`{{name}}` in a text header) | `"header": { "type": "text", "text": "Mei" }` | `"header.name": "{{contact.first_name}}"` |
| media header (image, video, document) | `"header": { "type": "image", "url": "https://…" }` | none: a campaign sends the approved header media |
| URL button suffix (`…/{{1}}`, button `index` 1) | `"buttons": [ { "index": 1, "url_suffix": "A-1042" } ]` | `"button.1.1": "{{contact.order_id}}"` |
| copy code (button `index` 2), up to 15 characters | `"buttons": [ { "index": 2, "coupon_code": "SAVE10" } ]` | `"button.2.copy_code": "SAVE10"` |
| quick-reply payload (button `index` 0) | `"buttons": [ { "index": 0, "payload": "stop" } ]` | none: sent as approved |

Older campaign keys that counted URL buttons only (`button.0.1`) are still
read where they cannot mean anything else.

### Raw components

For a template these fields cannot describe, send Meta's own array as
`template.components`, instead of `variables`, `header` and `buttons`. It is
passed to Meta as it is. Either way the values are checked against the
approved template first: a mismatch is `422 template_components_mismatch`,
and nothing is sent. Not supported yet: `currency` and `date_time`
parameters, `location` headers, product and carousel parameters,
limited-time offers, and flow, catalog, mpm, voice-call and one-tap
authentication buttons.

## The 24-hour window

WhatsApp lets a business send free-form text only within 24 hours of the
person's last message. Templates are allowed at any time, which is why the
API sends templates. Free-form WhatsApp text is not supported by the API yet.

## Opt-outs

A person who sends STOP, or taps a template's opt-out button, is opted out of
WhatsApp at once: later sends are `409 opted_out` until they send START, and
your team cannot opt them back in. The words and rules:
[What opts a number out](/platform-api/contacts/#what-opts-a-number-out).

## Status

`GET /v1/messages/{id}` reads a WhatsApp message like a text, with the
statuses in [Read a text's status](/platform-api/sms/#read-a-texts-status).
What differs on WhatsApp:

- `read` means the person opened it, and sets `read_at`.
- There is no `undelivered`: a message Meta could not deliver is `failed`.
- `segments` and `billed_credits` are always `null`, and so is `updated_at`:
  watch `sent_at`, `delivered_at` and `read_at`, or the webhook.
- `error.code` is Meta's own
  [error code](https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes)
  (`null` when we refused it before Meta was asked).

| Meta's code | `error.category` | what it means |
| --- | --- | --- |
| `131050` | `opted_out` | the person turned off marketing messages from businesses |
| `131049` | `carrier_filtered` | Meta held back a marketing message to limit how many the person gets |
| `131026` | `unreachable` | the number is not on WhatsApp, or its app is out of date |
| `131047` | `not_sent` | the 24-hour window is closed; only a template can be sent |
| `130429`, `131056` | `rate_limited` | too many messages too quickly |
| `131031`, `131042`, `131045`, `190` | `account` | a problem with the sending number or its business account |

`account` also covers a number not yet verified for sending, and
`blocked_by_route` a send your own sending rules refused. A code we have no
words for is `other`, with Meta's own text in `error.message`. `GET /v1/messages?channel=whatsapp&to=%2B6591234567` lists what you sent to
one person; `from` (one of your WhatsApp numbers) and `campaign` (a `ca-…` id)
narrow it the same way they do for SMS; without `channel`, texts are
listed too.

## Hear replies

A person's answer raises the `message.received`
[webhook](/platform-api/events/) with `channel: "whatsapp"`: branch on
`data.channel`.

- It fires for a text, button tap, list reply, location or file, whoever
  answers.
- `body` is the person's own words (text, caption or tapped button's label;
  empty for a file alone). A tap's button payload is in
  `quick_reply.payload`.
- Files are `GET /v1/media/{id}` links in `media`, as for MMS; the event
  fires once they are stored.
- `campaign_id` names the WhatsApp campaign this first replied to.
- An applied STOP or START has `keyword` `opt_out` or `opt_in`, as on SMS
  ([Opt-outs](#opt-outs)); the AI agent does not answer it.
- Not announced: reactions, messages only the WhatsApp Business App can
  show, your replies from the Business App, and imported chat history.
- A lost WhatsApp event is not repaired; the message is still in
  [`GET /v1/conversations/{id}/messages`](/platform-api/inbox/).

Where it is switched on, the AI agent can end a reply with choices: up to
three as reply buttons, up to ten as a list, otherwise a numbered list. A tap
arrives like any other, with the label as both `body` and
`quick_reply.payload`.

## Pricing

Each message costs 0.1 credits; Meta bills its own charges to you directly:
[WhatsApp pricing](/platform-api/billing/#whatsapp).

## When it fails

Common codes ([how errors work](/platform-api/errors/)):

| status | code | what to do |
|---|---|---|
| 422 | `template_not_approved` | wait for Meta, or send an approved template |
| 422 | `template_not_on_sender` | send from the number the template lists |
| 422 | `template_components_mismatch` | fill exactly what the template needs |
| 404 | `template_not_found` | check the id, or the name and language |
| 409 | `opted_out` | do not resend |
| 429 | `daily_limit_reached` | wait `Retry-After` seconds |

Every WhatsApp code, with its fix:
[Errors → WhatsApp messages](/platform-api/errors/#whatsapp-messages).

## Next

- [Run a campaign](/platform-api/campaigns/): one template to a whole list.
- [Receive webhooks](/platform-api/events/): delivered, read and replies, as they happen.
- [Test without spending](/platform-api/testing/): send yourself one first.