Skip to content

Send a WhatsApp template

Download .md

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:

Terminal window
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:

Terminal window
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:

Terminal window
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):

Terminal window
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):

Terminal window
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.

Terminal window
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" }
}
}'
{ "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.

  • from is your WhatsApp number, in any of the from forms.
  • 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.
  • Always send an Idempotency-Key. A retry with it never sends twice (how retries work).
  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).

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.

Terminal window
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.

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.

Terminal window
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.

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.

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.

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

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

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.

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.

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.

GET /v1/messages/{id} reads a WhatsApp message like a text, with the statuses in Read a text’s 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 (null when we refused it before Meta was asked).
Meta’s codeerror.categorywhat it means
131050opted_outthe person turned off marketing messages from businesses
131049carrier_filteredMeta held back a marketing message to limit how many the person gets
131026unreachablethe number is not on WhatsApp, or its app is out of date
131047not_sentthe 24-hour window is closed; only a template can be sent
130429, 131056rate_limitedtoo many messages too quickly
131031, 131042, 131045, 190accounta 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.

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.
  • 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); 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.

Each message costs 0.1 credits; Meta bills its own charges to you directly: WhatsApp pricing.

Common codes (how errors work):

statuscodewhat to do
422template_not_approvedwait for Meta, or send an approved template
422template_not_on_sendersend from the number the template lists
422template_components_mismatchfill exactly what the template needs
404template_not_foundcheck the id, or the name and language
409opted_outdo not resend
429daily_limit_reachedwait Retry-After seconds

Every WhatsApp code, with its fix: Errors → WhatsApp messages.