Send a template from your own system

One API call sends one approved WhatsApp template to one number — from a webhook, a cron job or a script. The key, the template id, the request, every refusal, and the two things to design around.

Updated

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.

  1. 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.
  2. 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 index 0.

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:

FieldWhat it is
toThe phone number, in E.164 — +14255550123
componentsMeta’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: text for a text header with a variable, or image, video or document with an https:// link.
  • Body — text parameters. For a positional template ({{1}}, {{2}}), send them in order with no names. For a named template ({{customer_name}}), give every parameter its parameter_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_type and index (its position in the template’s button list):
    • url — a text parameter: 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. isNewConversation is false.
  • If not, a new conversation is opened (and a contact created for an unknown number). isNewConversation is true.
  • 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

StatuscodeMeaning
400—to is not E.164, or components is not a shape Meta accepts; the body lists the problems
402billing_not_eligibleThe workspace cannot send outbound messages yet
404template_not_foundNo template with that id in this workspace
409blocked_dncThe number opted out. The message is kept in the thread as blocked; nothing reaches Meta
422template_not_approvedMeta has not approved the template; status says where it is
422template_components_mismatchcomponents does not fit the template. error names the problem
422channel_unavailableThe template’s WhatsApp number was removed or disabled
422channel_pending_verification, workspace_suspended, route_blockedA sending check on the number or the workspace refused it. The message is kept in the thread as blocked or failed
429workspace_rate_limitedYour workspace is over its rate on this endpoint. Nothing is written
429rate_limitedThe WhatsApp number’s burst window is full — wait seconds to minutes
429daily_limit_reachedThe number’s daily template cap is used up — the wait can be hours
502send_failedMeta 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