Some templates are not sent by a person at all. Tidewater Laundry Co. wants the pickup confirmation to go out the moment its booking system confirms a slot — nobody should have to open the inbox for that. For this there is one endpoint:
POST https://api.omniocto.com/api/wa-templates/{id}/send
It sends one approved template to one phone number. Call it from a webhook handler or a script — “when an order ships, send the pickup template”. Because it is a template, the 24-hour window does not apply (why).
Get an API key
Keys live at /settings/api-keys — in the gear menu, under Admin & System → API Keys.
- Click Create key. The dialog, Create API key, asks for a Name (optional) — something like “e.g. CI pipeline”; name it after the system that will use it.
- API key created shows the key once, with “Copy this key now — you won’t be able to see it again.” Store it where your system keeps secrets.
The list shows each key’s Last used date, and Revoke key stops it at once: “Any integration using it will fail.” One key per integration makes that safe to do.
Send the key on every call as Authorization: Bearer <key>.
Find the template id
Open /templates/whatsapp and click the template. The drawer opens and the
page address gains &template=wt-… — that wt- short id is what goes in the
path. (The template’s UUID works too.)
The same drawer tells you what the call has to fill in:
- Variables — the body’s variables: either “2 positional variables”
(
{{1}},{{2}}) or Named: followed by the names. - Header — its text, or
[IMAGE],[VIDEO]or[DOCUMENT]for a media header. - Buttons — listed in order, each with its type (
[URL],[COPY_CODE],[QUICK_REPLY]). The first one is index0.
From a script, GET /api/wa-templates?status=approved lists the approved
templates with shortId, name, language and category.
Only an approved template can be sent; any other status is refused.
The request
The body has two fields:
| Field | What it is |
|---|---|
to | The phone number, in E.164 — +14255550123 |
components | Meta’s own template.components array, passed through as-is. Leave it out for a template with no variables |
components is the same array you would send to Meta’s Cloud API, so Meta’s
documentation for it applies. In short:
- Header — one parameter:
textfor a text header with a variable, orimage,videoordocumentwith anhttps://link. - Body —
textparameters. For a positional template ({{1}},{{2}}), send them in order with no names. For a named template ({{customer_name}}), give every parameter itsparameter_name. Never mix the two. A template Octo created is positional here, whatever names you gave it. - Buttons — one component per button that takes a value, with
sub_typeandindex(its position in the template’s button list):url— atextparameter: the part of the link that varies.copy_code— the code, as Meta’s{ "type": "coupon_code", "coupon_code": "FRESH20" }or as{ "type": "text" }. Up to 15 characters.quick_reply— optional; a{ "type": "payload" }parameter if you want a payload back when it is tapped.
A complete example
Tidewater’s pickup template, pickup_confirmation, has the body
Hi {{1}}, your pickup is confirmed for {{2}}. Reply here to change it. and one
Track pickup button
whose link ends in the order number:
curl -X POST "https://api.omniocto.com/api/wa-templates/wt-a1b2c3d4e5/send" \
-H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14255550123",
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "Ana" },
{ "type": "text", "text": "Tuesday, 9–11am" }
] },
{ "type": "button", "sub_type": "url", "index": "0",
"parameters": [{ "type": "text", "text": "TW-20931" }] }
]
}'
Had the template used named variables, the body would name each value:
{ "type": "body", "parameters": [
{ "type": "text", "parameter_name": "customer_name", "text": "Ana" },
{ "type": "text", "parameter_name": "pickup_slot", "text": "Tuesday, 9–11am" }
] }
A success is 201:
{
"messageId": "6f1c…",
"conversationId": "3a9e…",
"conversationShortId": "cv-9f8e7d6c5b",
"externalId": "wamid.HBgL…",
"isNewConversation": false,
"template": { "id": "…", "shortId": "wt-a1b2c3d4e5", "name": "pickup_confirmation", "language": "en_US" }
}
externalId is the id Meta will report delivery and read receipts against;
those arrive on the conversation later. 201 means Meta accepted the send, not
that the phone received it. Keep conversationShortId — you need it below.
Where the message lands
In /conversations, in the conversation with that number on the template’s WhatsApp number — the same thread the customer’s messages arrive in:
- If a conversation with that number exists, the template joins it — and if it
was closed, it is reopened.
isNewConversationisfalse. - If not, a new conversation is opened (and a contact created for an unknown
number).
isNewConversationistrue. - The bubble shows the body with your values filled in, tagged Template · and the template’s name.
When the customer replies, the reply lands in that same thread — and an API send does not hand the thread to a person. A thread that already existed keeps whoever was handling it; a closed or new one is active under the AI agent that answers that WhatsApp number. So that agent, if the number has one, answers the reply, unless a person has taken the thread over in /conversations.
When a send is refused
| Status | code | Meaning |
|---|---|---|
| 400 | — | to is not E.164, or components is not a shape Meta accepts; the body lists the problems |
| 402 | billing_not_eligible | The workspace cannot send outbound messages yet |
| 404 | template_not_found | No template with that id in this workspace |
| 409 | blocked_dnc | The number opted out. The message is kept in the thread as blocked; nothing reaches Meta |
| 422 | template_not_approved | Meta has not approved the template; status says where it is |
| 422 | template_components_mismatch | components does not fit the template. error names the problem |
| 422 | channel_unavailable | The template’s WhatsApp number was removed or disabled |
| 422 | channel_pending_verification, workspace_suspended, route_blocked | A sending check on the number or the workspace refused it. The message is kept in the thread as blocked or failed |
| 429 | workspace_rate_limited | Your workspace is over its rate on this endpoint. Nothing is written |
| 429 | rate_limited | The WhatsApp number’s burst window is full — wait seconds to minutes |
| 429 | daily_limit_reached | The number’s daily template cap is used up — the wait can be hours |
| 502 | send_failed | Meta rejected the send; error carries Meta’s message. The message stays in the thread marked failed |
Every 429 carries a Retry-After header and a matching retryAfterSec in the
body. Wait that long and send the same call again: no message was sent, stored
or billed the first time. (A first send to a new number that the number’s
budget refuses has still opened the conversation, empty; the body carries its
conversationShortId.)
The two 429 families are different limits. Your workspace may call this endpoint 300 times a minute by default, in bursts of up to 10 a second; that rate counts API calls only, and Omni Octo support sets it per workspace. The WhatsApp number has its own sending budget, shared with the inbox, Octo and campaigns — how fast one number can send covers it.
Mismatches send nothing
The call checks components against the template before anything is
written. A missing value, an extra one, an unknown name, the wrong kind of
header, a parameter on a button that does not take one — each is a 422
template_components_mismatch, with no message stored and nothing sent. The
error says exactly what is wrong:
body expects 2 parameters, got 1
body is missing variable "pickup_slot"
button 0 is a QUICK_REPLY button, got url
button 0 (a URL button) is missing its url parameter
So a mismatch is safe to fix and resend.
The call is not idempotent
Every successful call sends — and bills — one template. There is no idempotency key. If a call times out or the connection drops, you do not know whether it went out, and a blind retry can send it twice.
Before retrying after a timeout, check the thread —
GET /api/conversations/{conversationShortId}
with the id from an earlier send to that number, or open the conversation in /conversations. Only resend if the template is not there.
The refusals above are different: a 4xx tells you the send did not happen.
What this endpoint will not do
- Send free text. It sends approved templates only — for a reply inside the window, use the inbox.
- Send to a list. One call is one number; campaigns handle lists.
- Override Meta. A marketing template can still be declined by Meta after a 201 — see why.
Where to go next
- Get your templates into Omni Octo — the template has to be approved and synced first.
- How fast one number can send
— the per-number budget behind
rate_limitedanddaily_limit_reached.