Skip to content

Messages

Send a WhatsApp message from your own systems — free-form text inside the 24-hour window, or an approved template outside it.

Open WhatsApp settings
POST/api/v1/messages

One endpoint, one job: put a WhatsApp message in front of a specific phone number, from one of your connected senders.

When you use this #

You use POST /messages when your system knows something the customer needs to hear and no conversation is in progress — an order shipped, a payment failed, an appointment is tomorrow, a ticket was resolved. Your backend already has the trigger; this endpoint is the last hop.

What it is not: a chat API. You do not use it to reply on behalf of an agent — a deployed agent answers inbound messages on its own, and those replies land in the inbox without you doing anything. You also cannot read messages back through it; there is no GET /api/v1/messages and no message list endpoint. To see what was said, read the conversation; to learn what happened to a message you sent, subscribe to the message.status webhook.

Scope required: messages:send. See Scopes.

WhatsApp is the only sendable channel

channel is a single-value enum today. Web chat is inbound-only from the visitor's browser, and voice is a call, not a message — that is POST /calls. Sending any other channel value is a schema error, not a capability error, and comes back as a 400 before anything is attempted.

Parameters #

channelstringoptionaldefault whatsapp

The only accepted value is whatsapp. Any other value fails request validation with 400 invalid_argument.

whatsapp
tostringrequired

Destination phone number. 6–20 characters, digits with an optional leading + — pattern ^\+?\d+$. No spaces, dashes, or parentheses.

textstringoptionaldefault null

Free-form message body, maximum 4096 characters. Only deliverable while the 24-hour window is open. Markdown is converted to WhatsApp formatting on the way out (**bold** becomes *bold*, a [link](url) becomes link: url). Supply this or template, not both.

templateobjectoptionaldefault null

An approved WhatsApp template to send instead of free-form text. See PublicTemplate below. Works whether the window is open or closed.

sender_idstringoptionaldefault null

Which connected WhatsApp sender to send from, maximum 64 characters. Omit to use the workspace's default sender. An id that is not a sender in this workspace is refused with 412 channel_not_connected. Get ids from GET /channels.

metadataobjectoptionaldefault {}

Free-form key/value object for your own bookkeeping. Never sent to the customer, not returned in the response, and not readable back through the API. Correlate on message_id instead.

The PublicTemplate object #

namestringrequired

The template name exactly as approved in your WhatsApp Business account. 1–120 characters. Template names are lowercase with underscores, for example order_shipped.

languagestringoptional

The template's language code, maximum 16 characters — for example en, en_US, or hi. Lookup prefers an exact match, then the same base language (en finds en_US), then an English variant, then any approved variant. Omit it and the English variant is used when there is one.

variablesarray | object | stringoptional

Values for the body placeholders. Either a positional array ["Asha", "VH-4821"] or a numbered object {"1": "Asha", "2": "VH-4821"}. A bare string fills a body with exactly one placeholder. The count must match the template exactly.

header_variablesarray | object | stringoptional

Values for placeholders in a text header. Separate from the body: header placeholders are numbered from 1 within the header. An image or video header needs no value here; the file attached to the template in the console is sent.

button_variablesarray | object | stringoptional

Values for placeholders in dynamic URL buttons. With one URL button, a string or an array is for that button. With several one-placeholder URL buttons, an array fills them in button order. Otherwise send an object keyed by the button's position in the template, counting from 0.

Either text or template — never neither #

Both fields are optional in the schema, but a request with neither is rejected before the provider is touched:

400 Bad Request
{
"error": {
"code": "invalid_argument",
"message": "Provide either `text` or `template`.",
"details": { "reason": "empty_message" },
"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
"correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
}
}

An empty string in text counts as nothing and produces the same empty_message reason. If you build the body dynamically, check for the empty case in your own code rather than discovering it as a 400 in production. A template render that returns "" is a bug in your system, and this error is where it surfaces.

Check for whitespace-only text yourself

A text made only of spaces or newlines is not caught by this check. Strip and test the value before you call, and never send a blank body.

If you send both text and template, the template is what goes out. Do not rely on that; pick one at the call site.

Text longer than 4096 characters is refused by validation. It is never split for you.

Phone number format #

to is validated against ^\+?\d+$ with a length between 6 and 20 characters. The pattern is deliberately strict: a number that reaches the provider with punctuation in it fails deep in the send path where the error is much harder to read, so it is rejected at the door instead.

Input Result
+919876543210 Accepted
919876543210 Accepted — the leading + is optional
+91 98765 43210 Rejected — spaces
+91-98765-43210 Rejected — dashes
(+91) 9876543210 Rejected — parentheses
+91.98765.43210 Rejected — dots
98765 43210 Rejected — spaces, and no country code

A rejected number comes back as a request-validation error:

400 Bad Request
{
"error": {
"code": "invalid_argument",
"message": "Request validation failed.",
"details": {
"errors": [
{
"loc": ["body", "to"],
"msg": "String should match pattern '^\\+?\\d+$'",
"type": "string_pattern_mismatch"
}
]
},
"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
"correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
}
}

There is no leniency layer and no country-code inference — strip everything that is not a digit or a leading + before you call. The to in the response is always normalised to + followed by digits, so you can store the response value as your canonical form.

Numbers stored by humans are rarely clean

If your numbers came from a CRM, a spreadsheet, or a web form, assume they carry spaces and dashes. Normalise once at the boundary — "+" + value.replace(/\D/g, "") in JavaScript, "+" + re.sub(r"\D", "", value) in Python — rather than at each call site.

Template variables, positionally or by number #

A template body contains placeholders {{1}}, {{2}}, and so on. You fill them either way:

Positional
{ "variables": ["Asha", "VH-4821", "3 October"] }
Numbered
{ "variables": { "1": "Asha", "2": "VH-4821", "3": "3 October" } }

The numbered form is worth the extra characters when the values come from different parts of your code and a silent reordering would be invisible — a positional array does not complain when you swap the customer name and the order reference, it just sends the wrong message.

Header and button placeholders are separate namespaces, each numbered from 1, and each has its own field:

A template with a header and a dynamic URL button
{
"to": "+919876543210",
"template": {
"name": "technician_visit",
"language": "en_US",
"header_variables": ["Veloria Home"],
"variables": ["Asha", "VH-4821"],
"button_variables": ["VH-4821"]
}
}

Put a header value in variables and you get a template_variables error, because the body then has one more value than it has placeholders. The message names the component and both counts, for example This template needs 2 body value(s); 3 were supplied. Count the placeholders in each component separately; that is almost always the discrepancy.

Each value is trimmed, newlines and tabs inside it become a single space, and it is cut at 1,024 characters, because WhatsApp rejects those characters inside a parameter.

The template must already be approved

This endpoint sends templates; it does not create them. The name you pass must exist in your WhatsApp Business account with status approved. Create and submit templates in the console — see Templates for the components, limits, and the review cycle.

Example #

Telling a customer their technician is on the way. They have not messaged you in days, so this is a template, not text.

Shell
curl -X POST https://services.amomic.in/api/v1/messages \
-H "Authorization: Bearer $AMOMIC_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-VH-4821-technician-assigned" \
-d '{
"to": "+919876543210",
"sender_id": "was_9f2c41ab77de1063c8a4b512",
"template": {
"name": "technician_assigned",
"language": "en_US",
"variables": ["Asha", "VH-4821", "Thu 15 Oct, 11:00–13:00"]
},
"metadata": { "job_id": "VH-4821", "source": "service-desk" }
}'

Free-form text is the same call with text in place of template:

Request body — only valid inside the 24-hour window
{
"to": "+919876543210",
"text": "Your AMC renewal of ₹2,360.00 is confirmed. The next free service is due in April."
}

Your order is on the way

Hi Asha, your technician for job *VH-4821* is booked for Thu 15 Oct, 11:00–13:00.

Veloria Home

10:04
Reschedule

Rendered with the same rules WhatsApp applies — what you see is what your customer gets.

What the template above renders as on the customer's phone. The variables you passed are substituted into the approved body.

Response #

201 Created
{
"message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMkI5RUE4RDU5OTQ2NUEwQjc4AA==",
"channel": "whatsapp",
"to": "+919876543210",
"status": "sent"
}
Field Type Notes
message_id string The provider's message id — a WhatsApp wamid.…. Store it: this is the join key for every later message.status webhook about this message.
channel string Always whatsapp today.
to string Your to, normalised to + followed by digits.
status string Always sent on a 201.

`sent` means accepted, not delivered

status: "sent" means WhatsApp accepted the message for delivery. It is not a delivery receipt and says nothing about whether the handset was reachable. Delivery outcomes arrive later on the message.status webhook, which carries WhatsApp's own status string: sent, delivered, read or failed. See Event reference for exactly which fields that event carries.

metadata is not echoed. To correlate the response with your own record, key off message_id, or use your own id as the Idempotency-Key.

A template send opens a billable conversation, the same as one message of a broadcast. It counts against your plan's included conversations, then your prepaid credits. See Credits.

Idempotency #

POST /messages honours the Idempotency-Key header. Send one on every call. A retry after a socket timeout replays the stored 201 instead of messaging the customer a second time, and the provider is never reached on the replay. Keys live for 24 hours and are scoped to your workspace, your specific API key, and this endpoint. Reusing a key with a different body is 409 idempotency_key_reuse. Sending the same key again while the first request is still running is 409 idempotency_in_progress with details.retry_after_seconds: 2: wait and retry with the same key. The full mechanics are in Idempotency.

Failures #

400invalid_argumentempty_messageNeither `text` nor `template` was supplied, or `text` was empty. Nothing was sent.
400invalid_argumenttemplate_variablesThe number or shape of variables does not match the approved template. The message names the component and both counts.
400invalid_argumentRequest validation failed: a bad `to` pattern, `text` over 4096 characters, an unknown `channel`, or a missing `template.name`. `details.errors` names the field.
401unauthenticatedKey missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. Read `details.reason` before assuming a bad secret.
403permission_deniedworkspace_suspendedThe workspace is suspended for billing. Every API key in it is refused until billing is restored.
403permission_deniedmissing_scopeThe key is valid but lacks `messages:send`. `details.required_scope` names it.
403permission_deniedsuppressedThe recipient opted out by replying STOP. Nothing is sent until they opt back in. This applies to templates too.
404not_foundtemplate_not_foundNo approved template with that name in this workspace. Check spelling, and check the template is `approved` rather than `pending` or `rejected`.
409conflictsession_window_closedFree-form `text` outside the 24-hour customer service window. Send an approved template instead.
409conflictidempotency_key_reuseThe same `Idempotency-Key` was already used with a different request body.
409conflictidempotency_in_progressA request with the same `Idempotency-Key` is still running. Wait `details.retry_after_seconds` (2) and retry with the same key.
412failed_preconditionallowance_exhaustedThe workspace has used every conversation its plan includes this month and has no prepaid credits left. Checked before anything is sent, for text and templates alike. Add credits or upgrade in Billing.
412failed_preconditionchannel_dormantThe workspace's plan no longer includes WhatsApp. The sender is held but sends nothing until the plan is upgraded.
412failed_preconditionchannel_not_connectedNo WhatsApp sender is connected, or `sender_id` is not a sender in this workspace.
412failed_preconditiontemplate_media_missingThe template has an image or video header, but no file is attached to it in this workspace. Add one under Channels → WhatsApp → Templates.
412failed_preconditionunsupported_channelWhatsApp sending is not available on this deployment. `details.supported` lists what is.
429rate_limitedrate_limited120 requests per minute per API key exceeded. `details.limit` and `details.window_seconds` are included.
502provider_errorWhatsApp refused the send. Rate-limit and server errors from WhatsApp are retried a few times first. `details` carries `provider_code`, `provider_subcode`, `provider_message` and `provider_user_message`.

The three you will actually hit #

session_window_closed (409). You sent text when the customer last messaged you more than 24 hours ago. This is the single most common integration surprise, and it is a WhatsApp platform rule rather than ours. The fix is a template, not a retry. The 24-hour window explains exactly when the window opens, what re-opens it, and why nothing is auto-queued for later.

The safe pattern for anything triggered by your own system rather than by the customer:

Node.js
// Do not branch on window state — you cannot query it, and it can close
// between your check and your send. Send the template and be done.
await sendTemplate(customer.phone, "order_shipped", [customer.name, order.ref]);

template_not_found (404). The message reads No approved WhatsApp template named '<name>'. Three causes, in order of likelihood: a typo, a template still sitting at pending in Meta's review queue, or a template that exists in a different language than the one you asked for. Template names are matched exactly and are lowercase with underscores.

channel_not_connected (412). No WhatsApp sender exists in the workspace, or the sender_id you passed is not one of its senders. Distinct from unsupported_channel, which means the deployment cannot do WhatsApp at all. Call GET /channels to see which is which: if message_channels contains whatsapp but senders is empty, you need to connect a number; if message_channels is empty, the channel is not on this deployment.

Two refusals that come from billing #

Both arrive before anything is sent, and neither clears on a retry.

  • allowance_exhausted (412). The workspace has used every conversation its plan includes this month, and there are no prepaid credits to cover more. It applies to free-form text as well as templates. Someone has to add credits or upgrade the plan in Billing. See What happens when you run out.
  • channel_dormant (412). A plan change took WhatsApp away. The number is still connected and held for a short time so an upgrade restores it, but it sends nothing. The error message says so in words you can show an operator.

412, not 501

Capability failures use 412 Precondition Failed rather than 501 or 503. The distinction that matters to your retry logic: a 412 will keep failing until someone changes configuration, so do not retry it on a backoff — surface it to a human.

What this endpoint does not do #

  • No media of your own. Text and approved templates only. A template with an image or video header sends the file attached to it in the console, but there is no way to send your own image, document, audio, video, sticker or location, and no upload endpoint.
  • No bulk send. One recipient per request. There is no batch endpoint, no audience model, and no scheduling parameter — loop, and respect the 120 requests/minute rate limit.
  • No read-back. No GET /api/v1/messages, no list endpoint. Read what was said through Conversations.
  • No delivery status query. Delivery outcomes are pushed to your webhook, not polled.

The 24-hour window

When text works and when it does not — the rule behind every session_window_closed.

Templates

Create, submit, and structure templates, including component limits and button types.

Order-shipped, end to end

A complete recipe with retries, idempotency, and the webhook that closes the loop.

Errors

Every code and reason in one place, with what to do about each.