Messages
Send a WhatsApp message from your own systems — free-form text inside the 24-hour window, or an approved template outside it.
/api/v1/messagesOne 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 whatsappThe only accepted value is whatsapp. Any other value fails request validation with 400 invalid_argument.
whatsapptostringrequiredDestination phone number. 6–20 characters, digits with an optional leading + — pattern ^\+?\d+$. No spaces, dashes, or parentheses.
textstringoptionaldefault nullFree-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 nullAn approved WhatsApp template to send instead of free-form text. See PublicTemplate below. Works whether the window is open or closed.
sender_idstringoptionaldefault nullWhich 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 #
namestringrequiredThe template name exactly as approved in your WhatsApp Business account. 1–120 characters. Template names are lowercase with underscores, for example order_shipped.
languagestringoptionalThe 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 | stringoptionalValues 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 | stringoptionalValues 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 | stringoptionalValues 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:
{"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:
{"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:
{ "variables": ["Asha", "VH-4821", "3 October"] }
{ "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:
{"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.
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:
{"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
Rendered with the same rules WhatsApp applies — what you see is what your customer gets.
Response #
{"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 #
invalid_argumentempty_messageNeither `text` nor `template` was supplied, or `text` was empty. Nothing was sent.invalid_argumenttemplate_variablesThe number or shape of variables does not match the approved template. The message names the component and both counts.invalid_argumentRequest validation failed: a bad `to` pattern, `text` over 4096 characters, an unknown `channel`, or a missing `template.name`. `details.errors` names the field.unauthenticatedKey missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. Read `details.reason` before assuming a bad secret.permission_deniedworkspace_suspendedThe workspace is suspended for billing. Every API key in it is refused until billing is restored.permission_deniedmissing_scopeThe key is valid but lacks `messages:send`. `details.required_scope` names it.permission_deniedsuppressedThe recipient opted out by replying STOP. Nothing is sent until they opt back in. This applies to templates too.not_foundtemplate_not_foundNo approved template with that name in this workspace. Check spelling, and check the template is `approved` rather than `pending` or `rejected`.conflictsession_window_closedFree-form `text` outside the 24-hour customer service window. Send an approved template instead.conflictidempotency_key_reuseThe same `Idempotency-Key` was already used with a different request body.conflictidempotency_in_progressA request with the same `Idempotency-Key` is still running. Wait `details.retry_after_seconds` (2) and retry with the same key.failed_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.failed_preconditionchannel_dormantThe workspace's plan no longer includes WhatsApp. The sender is held but sends nothing until the plan is upgraded.failed_preconditionchannel_not_connectedNo WhatsApp sender is connected, or `sender_id` is not a sender in this workspace.failed_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.failed_preconditionunsupported_channelWhatsApp sending is not available on this deployment. `details.supported` lists what is.rate_limitedrate_limited120 requests per minute per API key exceeded. `details.limit` and `details.window_seconds` are included.provider_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:
// 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.
Related #
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.