Messages
Send a WhatsApp message from a connected channel. Inbound messages are pushed to you as message.received webhooks, and both directions are also stored and readable through the message history routes (see Message history below).
Send a message
Which channel states accept a send:
| Channel status | Result |
|---|---|
connected | 202, status: "sent" — handed to WhatsApp now. |
connecting / reconnecting / disconnected, or pending / qr while re-pairing a channel that was paired before | 202, status: "queued" — held and sent once the channel connects. |
pending / qr on a channel that has never been paired | 409 channel_unavailable with details.paired: false — scan the QR code first. |
logged_out / stopped_by_user / disabled_by_admin / error / failed | 409 channel_unavailable — reconnect or reset the channel first. details.status says which state it is in. |
| Field | Type | Required | Notes |
|---|
The body is flat: type, to and the fields of that type sit side by side
at the top level (there is no nested body object). Valid shapes:
// text
{ "type": "text", "to": "5511999999999@s.whatsapp.net", "text": "hello" }
// image — mime_type is required for image / video / audio / document
{
"type": "image",
"to": "5511999999999@s.whatsapp.net",
"media_url": "https://example.com/photo.jpg",
"mime_type": "image/jpeg",
"caption": "look"
}
// audio — ptt: true renders a voice note (best with Ogg/Opus)
{
"type": "audio",
"to": "5511999999999@s.whatsapp.net",
"media_url": "https://example.com/note.ogg",
"mime_type": "audio/ogg; codecs=opus",
"ptt": true
}
// document
{
"type": "document",
"to": "5511999999999@s.whatsapp.net",
"media_url": "https://example.com/invoice.pdf",
"mime_type": "application/pdf",
"filename": "invoice.pdf"
}
// sticker (WebP — static or animated)
{
"type": "sticker",
"to": "5511999999999@s.whatsapp.net",
"media_url": "https://example.com/sticker.webp"
}Stickers must be WebP. mime_type is optional for stickers only and
defaults to image/webp.
to is a WhatsApp JID: the international number, digits only (no +),
followed by @s.whatsapp.net for a person, or a group id ending in @g.us.
A bare number such as "5511999999999" fails validation with
400 invalid_request on this route. (The typed alias
POST /v1/channels/:id/messages/:type does accept a bare number and adds the
suffix for you.) A @lid id, or a number WhatsApp says is not registered,
returns 400 undeliverable_recipient.
curl -sX POST "$WHATISUP_API/v1/channels/8d653c66-e4ff-43ee-97da-3de5ad5680d4/messages" \
-H "Authorization: Bearer $WHATISUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"text","to":"5511999999999@s.whatsapp.net","text":"hi"}'Errors this route returns:
| Status | error.code | When |
|---|---|---|
| 400 | invalid_request | The body failed validation (details.issues lists why). |
| 400 | undeliverable_recipient | to can't receive WhatsApp messages. details.to echoes it. |
| 402 | trial_expired / payment_overdue | Sending is paused on your account until you pick a plan or settle billing. |
| 404 | channel_not_found | No channel with that id on your account. |
| 409 | channel_unavailable | The channel is in a terminal state (see the table above). |
| 429 | rate_limited | Slow down — see Rate limits. |
| Field | Type | Required | Notes |
|---|---|---|---|
| message_id | string | required | |
| client_ref | string | required · nullable | |
| status | `sent` \| `queued` | required | |
| quoted | boolean | optional | |
| warnings | array<object> | optional |
The response is 202 Accepted, and status discriminates two outcomes:
"sent"— the channel was connected and the gateway handed the message to its WhatsApp connection. This is acceptance, not delivery: in message history the message readspendinguntil WhatsApp's servers acknowledge it, thensent→delivered→read."queued"— the channel was mid-recovery; we hold the message and retry it under the samemessage_iduntil it lands or the channel stops terminally. It shows up in message history right away aspending. If it can never go out — the channel stays offline for more than 2 hours, is logged out or stopped, or the recipient isn't on WhatsApp — you get onemessage.statuswebhook withstatus: "failed"and a machinereason(see payloads), and history showsfailed. A send to aconnectedchannel can also come back"queued": while older messages for that channel are still waiting in the queue, a new one lines up behind them rather than overtaking them.
A warnings array appears when there is something to know about a send that
still went ahead. Today: new_chat_restricted / new_chat_capped — WhatsApp
isn't letting this number start new chats right now and the recipient never
wrote to it, so WhatsApp will most likely refuse the message (it then turns
failed). The gateway never refuses the send itself; see
Keeping numbers healthy.
Both are success. Re-POSTing after a "queued" response creates a second
WhatsApp message — unless both requests carry the same Idempotency-Key header,
in which case the retry gets the original response back
(Idempotency).
Final delivery state arrives as message.status webhooks carrying
status: "sent" | "delivered" | "read" | "played" | "failed". There is no
separate message.delivered or message.failed event. Correlate on
message_id.
Reply to a message (quoted reply)
Add reply_to to any message-bearing send (text, image, video, audio,
document, sticker) and WhatsApp renders the original above your message —
exactly like swiping to reply in the app.
{
"type": "text",
"to": "5511999999999@s.whatsapp.net",
"text": "We open at 9am on Saturday.",
"reply_to": { "message_id": "3EB0AA11BB22CC33DD44EE55FF66007788AA" }
}reply_to.message_id is any message id this channel has seen: the
message_id from a message.received or message.sent webhook, the
quoted.message_id of an incoming reply, or the id returned by an earlier
send. You never have to remember WhatsApp's internal key — the gateway
rebuilds it (conversation + author) from its own message store.
The 202 answers whether the citation actually shipped:
{ "message_id": "3EB0…", "client_ref": null, "status": "sent", "quoted": true }quoted | Meaning |
|---|---|
| absent | The request carried no reply_to (or came back status: "queued" — see below). |
true | The message went out quoting that id. |
false | The id was unknown here — never seen on this channel, aged out of retention, or from a different conversation. The message was still sent, just without the citation. |
A bad reply_to never fails a send. Losing a quote is recoverable; losing the
message is not. If the citation matters to your flow, check quoted on the
response instead of assuming it.
On status: "queued" there is no quoted field: the channel was offline, so
the citation is resolved later, when the queued message actually sends. The
message.sent webhook carries the verdict in its own quoted field.
Quoting a message from a different conversation is not supported by
WhatsApp — the gateway drops the citation (quoted: false) rather than send a
key that renders as a dangling quote on the recipient's phone.
Message history
GET /v1/messages (every channel) and GET /v1/channels/:id/messages (one
channel) return stored inbound and outbound messages, newest first, with their
bodies, media references and the webhook deliveries that carried each one.
Outbound rows carry their delivery status (pending, sent,
delivered, read, played or failed) and status_updated_at, so you can
read a message's current state here as well as receive it as message.status
webhooks.
{
"data": [ { "id": "…", "direction": "out", "status": "delivered", "…": "…" } ],
"has_more": true,
"next_cursor": "2026-09-25T12:00:00.000Z",
"next_cursor_id": "0b8f…"
}Page with limit (1–100, default 20) and, for the next page, send the previous
response's next_cursor as before and next_cursor_id as before_id. When
a request returns an empty data, you've reached the end.
What you don't see in this API
- No single-message GET. There is no
GETfor one message id — find it in the history routes above, or track it throughmessage.statuswebhooks. - No bulk send. Loop the API; the per-customer rate limiter (API keys → Rate limiting) is your throttle. Stripe-style batching is on the roadmap.
- No "schedule send". Schedule it on your side and call us at fire time.
We emit message.status with status: "read" when WhatsApp reports the
recipient opened the chat, and "played" for voice and video notes — so read
receipts are available. The caveat is the recipient's own privacy setting:
WhatsApp suppresses the read receipt entirely when they have read receipts
turned off, so the absence of read is not proof the message is unread.