Sending messages
Send text and media, and understand why accepted is not delivered.
Messages
Send a message
POST /api/v1/messages
{
"connection_id": "bbf1ba6b-be14-491c-9d9f-8d8317f1e542",
"recipient": "+923001234567",
"body": "Your order #4821 has shipped."
}
| Field | Required | Notes |
|---|---|---|
connection_id |
yes | UUID of the connected number to send from |
recipient |
one of | Raw phone number, E.164, max 32 chars |
contact_id |
one of | UUID of an existing contact |
body |
no | Up to 4096 characters |
file |
no | A file upload, sent in the same request |
media.attachments[] |
no | Up to 10 previously uploaded media_ids |
Give either recipient or contact_id, never both — sending both is a
validation error rather than a silent preference, because guessing which one you
meant is how messages reach the wrong person.
An unknown recipient is added to your contacts automatically. You do not need
a create-contact call before your first message.
A message needs something to carry: body, a file, or at least one
attachment.
Response
HTTP/1.1 202 Accepted
{
"data": {
"id": "9bbe8e93-a33a-4221-9466-dc4847786444",
"direction": "outbound",
"status": "queued",
"message_type": "text",
"body": "Your order #4821 has shipped.",
"media": { "attachments": [] },
"failure_code": null,
"sent_at": null,
"delivered_at": null,
"read_at": null,
"created_at": "2026-07-20T12:04:11+00:00"
}
}
"Accepted" is not "delivered"
202 means the message is durably queued and we have taken responsibility for
it. It has not yet reached WhatsApp, let alone the recipient's phone.
This distinction is the single most common source of integration bugs. A message
can be accepted and still fail afterwards — the connection drops, the number is
not on WhatsApp, the recipient blocked you. Do not tell your user "sent" on a
202. Treat it as "queued", and let delivery state come from the dashboard or
a future delivery event.
An accepted message is also paced. Because these are session-based numbers, each number sends within a sending policy — a per-number rate (an hourly and daily cap) and a small random gap between sends — that protects it from being flagged as spam. A queued message leaves as soon as its turn in that policy comes up, so on a busy number or a large batch it can go out a little later than the instant you called the API. The policy is yours to configure in your workspace settings, and it is deliberately conservative by default. (One-to-one replies you send from the inbox are treated as conversational and are never paced.)
Sending media
Two ways, depending on whether you are sending the same file more than once.
One-shot: upload with the message
Send multipart/form-data with a file part:
curl -X POST https://your-domain.com/api/v1/messages \
-H 'Authorization: Bearer cap_a1b2c3d4e5f6.xxxx…' \
-F 'connection_id=bbf1ba6b-be14-491c-9d9f-8d8317f1e542' \
-F 'recipient=+923001234567' \
-F 'body=Here is your invoice' \
-F 'file=@invoice.pdf'
The upload is scanned for malware before the message is queued. A file that fails the scan is refused and its bytes are deleted; the message is not sent.
Reusable: upload once, send many times
Upload first:
POST /api/v1/media
Content-Type: multipart/form-data
Send the file as the file field. Maximum 64 MiB, and the type is checked by
inspecting the bytes rather than trusting the filename or the declared
content type.
A 201 returns the media object, whose id is the media_id you reference
below. Check state is retained before using it — that is the state that means
scanned, stored and sendable. Reference it in as many messages as you like:
{
"connection_id": "bbf1ba6b-...",
"recipient": "+923001234567",
"body": "This month's catalogue",
"media": { "attachments": [{ "media_id": "15bde241-97b6-4a35-af22-8b7bf2a5974a" }] }
}
Use this for a price list going to a thousand contacts: one upload, one scan, one stored copy.
Maximum 10 attachments per message.
POST /media does not honour Idempotency-Key. Uploading the same bytes twice
gives you two media objects, which costs storage but breaks nothing.
Retrying safely
Send an Idempotency-Key and a retry after a timeout cannot produce a duplicate
message. Without one it can. See
Errors and idempotency.
Consent
Sending is subject to your workspace's consent rules.
A contact whose consent is unknown can receive messages — the API treats a send as your assertion that a relationship exists. A contact who has opted out receives nothing at all: the block is unconditional and applies to transactional sends too, not only campaigns.
{ "message": "The contact has opted out of outbound messages." }
A suppressed contact is refused the same way. Consent blocks are refused at the
API with a 422, never silently dropped, so an order notification to an
opted-out contact fails loudly rather than vanishing.