API reference

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.

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.

Was this guide useful? Report an issue