# Messages

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

Source: https://docs.amomic.in/api/messages

---

```endpoint
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](/api/conversations); to learn what happened to a message you sent, subscribe to the `message.status` [webhook](/webhooks/events).

Scope required: `messages:send`. See [Scopes](/api/scopes).

::: note 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`](/api/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

```params
[
  {"name": "channel", "type": "string", "required": false, "default": "whatsapp", "enum": ["whatsapp"], "description": "The only accepted value is <code>whatsapp</code>. Any other value fails request validation with <code>400 invalid_argument</code>."},
  {"name": "to", "type": "string", "required": true, "description": "Destination phone number. 6–20 characters, digits with an optional leading <code>+</code> — pattern <code>^\\+?\\d+$</code>. No spaces, dashes, or parentheses."},
  {"name": "text", "type": "string", "required": false, "default": "null", "description": "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 (<code>**bold**</code> becomes <code>*bold*</code>, a <code>[link](url)</code> becomes <code>link: url</code>). Supply this or <code>template</code>, not both."},
  {"name": "template", "type": "object", "required": false, "default": "null", "description": "An approved WhatsApp template to send instead of free-form text. See <code>PublicTemplate</code> below. Works whether the window is open or closed."},
  {"name": "sender_id", "type": "string", "required": false, "default": "null", "description": "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 <code>412 channel_not_connected</code>. Get ids from <a href=\"/api/discovery\">GET /channels</a>."},
  {"name": "metadata", "type": "object", "required": false, "default": "{}", "description": "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 <code>message_id</code> instead."}
]
```

### The `PublicTemplate` object

```params
[
  {"name": "name", "type": "string", "required": true, "description": "The template name exactly as approved in your WhatsApp Business account. 1–120 characters. Template names are lowercase with underscores, for example <code>order_shipped</code>."},
  {"name": "language", "type": "string", "required": false, "description": "The template's language code, maximum 16 characters — for example <code>en</code>, <code>en_US</code>, or <code>hi</code>. Lookup prefers an exact match, then the same base language (<code>en</code> finds <code>en_US</code>), then an English variant, then any approved variant. Omit it and the English variant is used when there is one."},
  {"name": "variables", "type": "array | object | string", "required": false, "description": "Values for the body placeholders. Either a positional array <code>[\"Asha\", \"VH-4821\"]</code> or a numbered object <code>{\"1\": \"Asha\", \"2\": \"VH-4821\"}</code>. A bare string fills a body with exactly one placeholder. The count must match the template exactly."},
  {"name": "header_variables", "type": "array | object | string", "required": false, "description": "Values for placeholders in a <strong>text</strong> 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."},
  {"name": "button_variables", "type": "array | object | string", "required": false, "description": "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 <code>0</code>."}
]
```

### 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:

```json title="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.

::: warning 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:

```json title="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.

::: warning 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:

```json title="Positional"
{ "variables": ["Asha", "VH-4821", "3 October"] }
```

```json title="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:

```json title="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.

::: note 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](/channels/whatsapp-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.

::: codegroup

```bash cURL
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" }
  }'
```

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";

async function sendTechnicianAssigned(job) {
  const response = await fetch(`${BASE}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `job-${job.id}-technician-assigned`,
    },
    body: JSON.stringify({
      to: `+${job.customerPhone.replace(/\D/g, "")}`,
      sender_id: process.env.AMOMIC_SENDER_ID,
      template: {
        name: "technician_assigned",
        language: "en_US",
        variables: [job.customerName, job.id, job.slotLabel],
      },
      metadata: { job_id: job.id, source: "service-desk" },
    }),
  });

  const payload = await response.json();
  if (!response.ok) {
    const { code, message, details, request_id } = payload.error;
    throw new Error(`${code}/${details.reason ?? "-"}: ${message} (${request_id})`);
  }
  return payload.message_id; // wamid.… — the join key for message.status
}
```

```python Python
import os
import re

import requests

BASE = "https://services.amomic.in/api/v1"


def send_technician_assigned(job) -> str:
    response = requests.post(
        f"{BASE}/messages",
        headers={
            "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
            "Idempotency-Key": f"job-{job.id}-technician-assigned",
        },
        json={
            "to": "+" + re.sub(r"\D", "", job.customer_phone),
            "sender_id": os.environ["AMOMIC_SENDER_ID"],
            "template": {
                "name": "technician_assigned",
                "language": "en_US",
                "variables": [job.customer_name, job.id, job.slot_label],
            },
            "metadata": {"job_id": job.id, "source": "service-desk"},
        },
        timeout=30,
    )
    if not response.ok:
        error = response.json()["error"]
        raise RuntimeError(
            f"{error['code']}/{error['details'].get('reason', '-')}: "
            f"{error['message']} ({error['request_id']})"
        )
    return response.json()["message_id"]
```

:::

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

```json title="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."
}
```

```mock
{"component": "WhatsAppMock", "props": {"body": "Hi Asha, your technician for job *VH-4821* is booked for Thu 15 Oct, 11:00–13:00.", "footer": "Veloria Home", "buttons": ["Reschedule"], "time": "10:04", "caption": "What the template above renders as on the customer's phone. The variables you passed are substituted into the approved body."}}
```

## Response

```json title="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. |

::: warning `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](/webhooks/events) 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](/operate/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](/api/idempotency).

## Failures

```responses
[
  {"status": 400, "code": "invalid_argument", "reason": "empty_message", "description": "Neither `text` nor `template` was supplied, or `text` was empty. Nothing was sent."},
  {"status": 400, "code": "invalid_argument", "reason": "template_variables", "description": "The number or shape of variables does not match the approved template. The message names the component and both counts."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "Request validation failed: a bad `to` pattern, `text` over 4096 characters, an unknown `channel`, or a missing `template.name`. `details.errors` names the field."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "Key missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. Read `details.reason` before assuming a bad secret."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended for billing. Every API key in it is refused until billing is restored."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "The key is valid but lacks `messages:send`. `details.required_scope` names it."},
  {"status": 403, "code": "permission_denied", "reason": "suppressed", "description": "The recipient opted out by replying STOP. Nothing is sent until they opt back in. This applies to templates too."},
  {"status": 404, "code": "not_found", "reason": "template_not_found", "description": "No approved template with that name in this workspace. Check spelling, and check the template is `approved` rather than `pending` or `rejected`."},
  {"status": 409, "code": "conflict", "reason": "session_window_closed", "description": "Free-form `text` outside the 24-hour customer service window. Send an approved template instead."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "The same `Idempotency-Key` was already used with a different request body."},
  {"status": 409, "code": "conflict", "reason": "idempotency_in_progress", "description": "A request with the same `Idempotency-Key` is still running. Wait `details.retry_after_seconds` (2) and retry with the same key."},
  {"status": 412, "code": "failed_precondition", "reason": "allowance_exhausted", "description": "The 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."},
  {"status": 412, "code": "failed_precondition", "reason": "channel_dormant", "description": "The workspace's plan no longer includes WhatsApp. The sender is held but sends nothing until the plan is upgraded."},
  {"status": 412, "code": "failed_precondition", "reason": "channel_not_connected", "description": "No WhatsApp sender is connected, or `sender_id` is not a sender in this workspace."},
  {"status": 412, "code": "failed_precondition", "reason": "template_media_missing", "description": "The template has an image or video header, but no file is attached to it in this workspace. Add one under Channels → WhatsApp → Templates."},
  {"status": 412, "code": "failed_precondition", "reason": "unsupported_channel", "description": "WhatsApp sending is not available on this deployment. `details.supported` lists what is."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 requests per minute per API key exceeded. `details.limit` and `details.window_seconds` are included."},
  {"status": 502, "code": "provider_error", "reason": "", "description": "WhatsApp 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](/channels/whatsapp-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:

```javascript
// 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`](/api/discovery) 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](/operate/credits#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.

::: note 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](/api/rate-limits).
- **No read-back.** No `GET /api/v1/messages`, no list endpoint. Read what was said through [Conversations](/api/conversations).
- **No delivery status query.** Delivery outcomes are pushed to your [webhook](/webhooks/events), not polled.

## Related

::: cards

::: card The 24-hour window
[When text works and when it does not](/channels/whatsapp-window) — the rule behind every `session_window_closed`.
:::

::: card Templates
[Create, submit, and structure templates](/channels/whatsapp-templates), including component limits and button types.
:::

::: card Order-shipped, end to end
[A complete recipe](/recipes/order-updates) with retries, idempotency, and the webhook that closes the loop.
:::

::: card Errors
[Every code and reason](/api/errors) in one place, with what to do about each.
:::

:::
