Sending messages
One endpoint sends every message. It accepts the message, answers immediately, and delivers asynchronously — so the interesting parts are what the request accepts, what the states mean, and how to make a retry safe.
Send a message
POST/v1/messagesA 201 means accepted, not delivered. The message is queued and dispatched by a worker; watch it with the message endpoints or with webhooks.
curl -X POST https://whats.azzamkh.sa/api/v1/messages \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "ch_...",
"to": "+9665XXXXXXXX",
"type": "text",
"text": { "body": "Your code is 481902" }
}'
# 201 Created
# {
# "id": "msg_...",
# "status": "queued",
# "channel_id": "ch_...",
# "to": "+9665XXXXXXXX",
# "type": "text",
# "category": "utility",
# "created_at": "2026-08-13T10:00:00.000Z"
# }Request body
Only to is unconditionally required. text is required when the type is text, and template.slug when it is template.
| Field | Type | Required | Description |
|---|---|---|---|
| to | string | Required | The recipient in strict E.164, e.g. +9665XXXXXXXX. Anything else is a validation error naming the field. |
| type | "text" | "template" | Optional | Defaults to text. |
| text.body | string | Optional | The message body, 1 to 4096 characters. Required for a text message. |
| template.slug | string | Optional | The template's slug. Required for a template message. |
| template.language | string | Optional | The template's language tag. A template is identified by slug and language together. |
| template.variables | object<string, string> | Optional | Values for the template's placeholders. A missing required value is a 422. |
| channel_id | string | Optional | Omit it and the workspace's default channel is used. Name it when the workspace has more than one. |
| category | "authentication" | "utility" | "marketing" | Optional | Drives queue priority. Defaults to utility. A template's own category takes precedence over what you send here. |
Unknown fields are rejected
The body and both of its sub-objects are strict: a misspelled field is a 422 VALIDATION_ERROR naming it, not a silently ignored value. This is the behaviour you want — a typo in a field name fails loudly instead of sending a message without it.
The type enum is exactly "text", "template". Sending an image, a document, audio or video is not yet available on this endpoint; inbound media still arrives and is recorded on the message.
Response
The send response is deliberately small. Read the message back if you need its conversation_id, its provider id, or its timeline — none of those exist yet at acceptance time.
| Field | Type | Description |
|---|---|---|
| id | string | The message's public id. Use it for every follow-up. |
| status | string | Always queued on acceptance. |
| channel_id | string | The channel the message will be sent from, resolved if you omitted it. |
| to | string | The recipient, normalised to E.164. |
| type | string | Echoes the requested type. |
| category | string | The classified category, which may differ from the one you sent if a template overrode it. |
| created_at | string | When the message row was created. |
Sending a template
Set the type to template and name the slug and language. See templates for how the content and its variables are defined.
curl -X POST https://whats.azzamkh.sa/api/v1/messages \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "ch_...",
"to": "+9665XXXXXXXX",
"type": "template",
"category": "utility",
"template": {
"slug": "order_confirmation",
"language": "ar",
"variables": { "order_id": "A-10428", "name": "سارة" }
}
}'Categories and priority
The category describes what a message is for, and the platform uses it to decide what goes first when a channel is saturated.
| Category | Priority | Description |
|---|---|---|
| authentication | Highest | One-time passwords and verification codes. Time-critical: a code that arrives late is worthless. |
| utility | Normal | Transactional messages about something the recipient already did — an order, a booking, a delivery. |
| marketing | Lowest | Promotional content. Lowest priority, and the category most constrained by policy. |
Omitting the category classifies the message as utility.
Priority orders the queue; it does not add capacity
When a channel is at its throughput, an authentication message overtakes a marketing one. When it is not, priority changes nothing. It is not a way to raise a limit.
Idempotency
Send an Idempotency-Key header on every send. It is optional, and it is the difference between a network timeout costing you nothing and costing you a duplicate message.
| Situation | Result |
|---|---|
| First use of a key | The message is created and the response is stored against the key. |
| Same key, identical body | The stored response is replayed byte for byte, with an Idempotent-Replay: true header. No second message is created. |
| Same key, different body | 409 IDEMPOTENCY_CONFLICT. Nothing is sent. |
| Same key, first request still running | 409 CONFLICT with details.reason = idempotent_request_in_progress. Retry shortly. |
| The first request failed | The key is released, so the same key can be retried. You see the original error. |
KEY=$(uuidgen)
# First call: the message is created.
curl -X POST https://whats.azzamkh.sa/api/v1/messages \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Idempotency-Key: $KEY" \
-d '{ "to": "+9665XXXXXXXX", "type": "text", "text": { "body": "Hello" } }'
# 201 Created { "id": "msg_A", "status": "queued", ... }
# Same key, IDENTICAL body: the stored response is replayed byte for byte.
# No second message is created.
# 201 Created
# idempotent-replay: true
# { "id": "msg_A", "status": "queued", ... }
# Same key, DIFFERENT body:
# 409 Conflict
# { "error": { "code": "IDEMPOTENCY_CONFLICT", ... } }- Bodies are compared by a hash of their canonical form, so key order and whitespace do not matter — only the values do.
- Keys are scoped to your workspace. Two customers can use the same key value without colliding.
- A key is remembered for
24hours, then forgotten. Reusing it after that sends a new message. - A key is at most
255characters. A UUID is a good choice.
States and the timeline
docs.pages.messages.lifecycle.body
| State | Description | Can move to |
|---|---|---|
| created | The row exists; nothing has been queued yet. | queued, cancelled, failed_permanent |
| queued | Accepted and waiting for a worker. This is what a send returns. | dispatching, cancelled, expired, failed_retryable, failed_permanent |
| dispatching | A worker has picked it up and is talking to the provider. | provider_accepted, sent, failed_retryable, failed_permanent, cancelled |
| provider_accepted | The provider took it but has not confirmed sending. | sent, delivered, read, failed_retryable, failed_permanent, expired |
| sent | Handed to WhatsApp. | delivered, read, failed_permanent, expired |
| delivered | Delivered to the recipient's device. | read, failed_permanent |
| read | Read by the recipient, where the provider reports it. | — |
| failed_retryable | Failed in a way that will be retried. Not final. | queued, dispatching, failed_permanent, cancelled, expired |
| failed_permanent | Failed for good. Final. | — |
| cancelled | Stopped before it was sent — for example when the dispatch queue could not be reached. | — |
| expired | Its delivery window elapsed before it could be sent. | — |
Four states are terminal, and nothing follows them: read, failed_permanent, cancelled, expired.
There is no plain failed state
A failure is either failed_retryable or failed_permanent, and the two mean opposite things for your code. Treating them as one is the most common mistake here: a retryable failure is still in flight.
Reading messages back
Filters
| Parameter | Values | Description |
|---|---|---|
| status | created, queued, dispatching, provider_accepted, … | Filter by state. `state` is accepted as an alias for the same parameter. |
| direction | outbound, inbound | Outbound messages you sent, or inbound ones you received. |
| category | authentication, utility, marketing | Filter by category. |
| channel_id | ch_… | Filter to one channel. An unknown channel id is a 404, not an empty page. |
| limit, after | — | Cursor pagination. Newest first. |
An unknown filter value is an error
Unlike some list endpoints elsewhere in this API, the message filters reject a value they do not recognise with a 422 that names the allowed set. A typo in a filter never quietly returns everything.
One message, with its timeline
Reading a single message adds an events array, oldest first: every state transition with its source, its attempt number and its reason. This is the record to look at when a message did not arrive.
curl https://whats.azzamkh.sa/api/v1/messages/msg_... \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK
# {
# "id": "msg_...",
# "status": "delivered",
# "direction": "outbound",
# "attempts": 1,
# "queued_at": "2026-08-13T10:00:00.000Z",
# "sent_at": "2026-08-13T10:00:01.140Z",
# "delivered_at": "2026-08-13T10:00:02.980Z",
# "read_at": null,
# "events": [
# { "id": "mev_...", "from_status": null, "status": "created", ... },
# { "id": "mev_...", "from_status": "created", "status": "queued", ... },
# { "id": "mev_...", "from_status": "queued", "status": "dispatching", ... },
# { "id": "mev_...", "from_status": "dispatching", "status": "sent", ... },
# { "id": "mev_...", "from_status": "sent", "status": "delivered", ... }
# ]
# }What can go wrong
All of these follow the envelope in errors and limits. A failure after acceptance is not an HTTP error at all — it arrives as a state change and a webhook.
| Code | HTTP | When it happens |
|---|---|---|
| VALIDATION_ERROR | 422 | A malformed body: a non-E.164 recipient, a missing text body, an unknown field. |
| CHANNEL_NOT_FOUND | 404 | The named channel does not exist in this workspace. |
| CHANNEL_NOT_CONNECTED | 409 | The channel exists but is not connected. |
| CHANNEL_CAPABILITY_UNAVAILABLE | 409 | The channel cannot do what the message needs, or its capabilities have never been refreshed. |
| SANDBOX_TEST_KEY_REQUIRED | 403 | A live key was used on a sandbox channel. |
| SANDBOX_RECIPIENT_NOT_VERIFIED | 403 | The recipient has not verified ownership on this sandbox session, or is a different number from the verified one. |
| TEMPLATE_NOT_SENDABLE | 409 | The template is a draft, paused or disabled. |
| TEMPLATE_VARIABLE_MISSING | 422 | A required template variable had no value. details lists which. |
| ENTITLEMENT_LIMIT_REACHED | 409 | A plan limit was reached. details names the entitlement, the limit and the current count. |
| IDEMPOTENCY_CONFLICT | 409 | The idempotency key was already used with a different body. |
| QR_SAFETY_LIMIT_REACHED | 429 | The QR channel's safety limit for the current window is spent. |
| SERVICE_UNAVAILABLE | 503 | The dispatch queue is unreachable. The message is cancelled rather than left in limbo; retry the send. |