Send a WhatsApp template
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). Every field: reference.
Recipe: send a template and read the reply
Section titled “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:
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:
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}'{ "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:
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):
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):
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. Answer in the thread within 24 hours.
Send one template
Section titled “Send one template”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" } } }'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);import osimport uuidimport 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"]){ "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, which tells you when it is delivered and read.
fromis your WhatsApp number, in any of thefromforms.tois the recipient in E.164; anything else is400 invalid_number.metadata(optional): your own key/value pairs, with the same limits as on a text.- Always send an
Idempotency-Key. A retry with it never sends twice (how retries work).
Before your first message
Section titled “Before your first message”- A WhatsApp number connected to the workspace, once, in the dashboard under Channels.
- A template Meta has approved on that number. Create and submit templates in the dashboard; Meta reviews each one.
- Credits (Pricing).
GET /v1/senders shows each WhatsApp number with a whatsapp block:
{ "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
Section titled “Find a template”curl "https://api.omniocto.com/v1/templates?from=%2B16285550100" -H "Authorization: Bearer $OMNI_OCTO_API_KEY"{ "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.
Read one template
Section titled “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.
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:
"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
Section titled “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
Section titled “Fill the template”Fill the variables
Section titled “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:
"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
Section titled “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:
"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).
Single send vs campaign keys
Section titled “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
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
Section titled “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
Section titled “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
Section titled “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.
Status
Section titled “Status”GET /v1/messages/{id} reads a WhatsApp message like a text, with the
statuses in Read a text’s status.
What differs on WhatsApp:
readmeans the person opened it, and setsread_at.- There is no
undelivered: a message Meta could not deliver isfailed. segmentsandbilled_creditsare alwaysnull, and so isupdated_at: watchsent_at,delivered_atandread_at, or the webhook.error.codeis Meta’s own error code (nullwhen 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
Section titled “Hear replies”A person’s answer raises the message.received
webhook with channel: "whatsapp": branch on
data.channel.
- It fires for a text, button tap, list reply, location or file, whoever answers.
bodyis the person’s own words (text, caption or tapped button’s label; empty for a file alone). A tap’s button payload is inquick_reply.payload.- Files are
GET /v1/media/{id}links inmedia, as for MMS; the event fires once they are stored. campaign_idnames the WhatsApp campaign this first replied to.- An applied STOP or START has
keywordopt_outoropt_in, as on SMS (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.
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
Section titled “Pricing”Each message costs 0.1 credits; Meta bills its own charges to you directly: WhatsApp pricing.
When it fails
Section titled “When it fails”Common codes (how errors work):
| 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.
- Run a campaign: one template to a whole list.
- Receive webhooks: delivered, read and replies, as they happen.
- Test without spending: send yourself one first.