Skip to content
Contents
Sending and receiving

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/messages

A 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.

FieldTypeRequiredDescription
tostringRequiredThe recipient in strict E.164, e.g. +9665XXXXXXXX. Anything else is a validation error naming the field.
type"text" | "template"OptionalDefaults to text.
text.bodystringOptionalThe message body, 1 to 4096 characters. Required for a text message.
template.slugstringOptionalThe template's slug. Required for a template message.
template.languagestringOptionalThe template's language tag. A template is identified by slug and language together.
template.variablesobject<string, string>OptionalValues for the template's placeholders. A missing required value is a 422.
channel_idstringOptionalOmit it and the workspace's default channel is used. Name it when the workspace has more than one.
category"authentication" | "utility" | "marketing"OptionalDrives 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.

FieldTypeDescription
idstringThe message's public id. Use it for every follow-up.
statusstringAlways queued on acceptance.
channel_idstringThe channel the message will be sent from, resolved if you omitted it.
tostringThe recipient, normalised to E.164.
typestringEchoes the requested type.
categorystringThe classified category, which may differ from the one you sent if a template overrode it.
created_atstringWhen 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
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.

CategoryPriorityDescription
authenticationHighestOne-time passwords and verification codes. Time-critical: a code that arrives late is worthless.
utilityNormalTransactional messages about something the recipient already did — an order, a booking, a delivery.
marketingLowestPromotional 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.

SituationResult
First use of a keyThe message is created and the response is stored against the key.
Same key, identical bodyThe stored response is replayed byte for byte, with an Idempotent-Replay: true header. No second message is created.
Same key, different body409 IDEMPOTENCY_CONFLICT. Nothing is sent.
Same key, first request still running409 CONFLICT with details.reason = idempotent_request_in_progress. Retry shortly.
The first request failedThe key is released, so the same key can be retried. You see the original error.
curl
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 24 hours, then forgotten. Reusing it after that sends a new message.
  • A key is at most 255 characters. A UUID is a good choice.

States and the timeline

docs.pages.messages.lifecycle.body

StateDescriptionCan move to
createdThe row exists; nothing has been queued yet.queued, cancelled, failed_permanent
queuedAccepted and waiting for a worker. This is what a send returns.dispatching, cancelled, expired, failed_retryable, failed_permanent
dispatchingA worker has picked it up and is talking to the provider.provider_accepted, sent, failed_retryable, failed_permanent, cancelled
provider_acceptedThe provider took it but has not confirmed sending.sent, delivered, read, failed_retryable, failed_permanent, expired
sentHanded to WhatsApp.delivered, read, failed_permanent, expired
deliveredDelivered to the recipient's device.read, failed_permanent
readRead by the recipient, where the provider reports it.
failed_retryableFailed in a way that will be retried. Not final.queued, dispatching, failed_permanent, cancelled, expired
failed_permanentFailed for good. Final.
cancelledStopped before it was sent — for example when the dispatch queue could not be reached.
expiredIts 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

GET/v1/messagesGET/v1/messages/{messageId}

Filters

ParameterValuesDescription
statuscreated, queued, dispatching, provider_accepted, …Filter by state. `state` is accepted as an alias for the same parameter.
directionoutbound, inboundOutbound messages you sent, or inbound ones you received.
categoryauthentication, utility, marketingFilter by category.
channel_idch_…Filter to one channel. An unknown channel id is a 404, not an empty page.
limit, afterCursor 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
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.

CodeHTTPWhen it happens
VALIDATION_ERROR422A malformed body: a non-E.164 recipient, a missing text body, an unknown field.
CHANNEL_NOT_FOUND404The named channel does not exist in this workspace.
CHANNEL_NOT_CONNECTED409The channel exists but is not connected.
CHANNEL_CAPABILITY_UNAVAILABLE409The channel cannot do what the message needs, or its capabilities have never been refreshed.
SANDBOX_TEST_KEY_REQUIRED403A live key was used on a sandbox channel.
SANDBOX_RECIPIENT_NOT_VERIFIED403The recipient has not verified ownership on this sandbox session, or is a different number from the verified one.
TEMPLATE_NOT_SENDABLE409The template is a draft, paused or disabled.
TEMPLATE_VARIABLE_MISSING422A required template variable had no value. details lists which.
ENTITLEMENT_LIMIT_REACHED409A plan limit was reached. details names the entitlement, the limit and the current count.
IDEMPOTENCY_CONFLICT409The idempotency key was already used with a different body.
QR_SAFETY_LIMIT_REACHED429The QR channel's safety limit for the current window is spent.
SERVICE_UNAVAILABLE503The dispatch queue is unreachable. The message is cancelled rather than left in limbo; retry the send.