Send messages
Send text, media albums, polls, locations, and contact cards through one REST endpoint, with idempotent retries and per-session rate limits.
Sending is one POST to a session's messages endpoint. You need a session that is
already connected (its status is working). Every request body has two required
fields:
type— what kind of message to send.to— the recipient's JID. A JID is WhatsApp's address for a chat: a phone JID like628123456789@s.whatsapp.netfor a one-to-one chat, or a group JID for a group.
The other fields depend on type.
curl -X POST https://your-gateway/api/v1/sessions/{id}/messages \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"text","to":"628123456789@s.whatsapp.net","text":"Hello"}'The reply is a SendResult: waMessageId, status, timestamp, and the
durable outboxId for an accepted command. The full request and response shapes are in
send a message.
Each session's overview in the dashboard has a Send a test message card with this request ready to copy, with the session ID filled in. To send text, polls, locations, and contact cards by hand, use the session's Chats page.
Message types
type | Required fields | Optional fields |
|---|---|---|
text | text | mentions (a list of JIDs to tag), replyTo (a message id to quote) |
buttons | text, buttons: [{id, title}] | footer, replyTo, mentions |
list (experimental) | text, list: {title, sections: [{title, rows: [{id, title}]}]} | row description, footer, replyTo, mentions |
poll | name, options | selectableCount — how many options one voter may pick. Votes come back as poll.vote events. |
location | latitude, longitude | name — a label for the pin |
contact | contact | — |
image, video, audio, document, sticker | exactly one of media.data or media.url | media.mimetype, media.caption (image/video/document), media.filename (document), replyTo, mentions |
album | medias with 2–10 image/video items | caption, replyTo, mentions |
A contact is a card. Give it { name, phone }, or supply a raw vcard string
instead.
Media
Media is sent from exactly one source:
media.data— the file's bytes, base64-encoded. Adata:URI (e.g. what a browser'sFileReader.readAsDataURLproduces) is accepted too; its MIME type is used when you don't setmedia.mimetype.media.url— a public HTTP(S) URL for the gateway to download before uploading to WhatsApp.
The decoded file must be 16 MiB or smaller. mimetype is optional but
recommended; filename shows on documents; caption shows under image, video,
and document messages.
{
"type": "image",
"to": "628123456789@s.whatsapp.net",
"media": {
"data": "iVBORw0KGgoAAAANSUhEUgAA...",
"mimetype": "image/png",
"caption": "Here you go!"
}
}URL media uses the same shape:
{
"type": "image",
"to": "628123456789@s.whatsapp.net",
"media": {
"url": "https://example.com/photo.jpg",
"caption": "Here you go!"
}
}The same shape sends video, audio, document, and sticker — change type
(and add media.filename for a document).
Albums
An album groups 2–10 images or videos under one optional caption. Put the caption
at the top level; WhatsApp supports one caption for the grouped album. Every item
may use either base64 data or a public url, and the two source forms can be
mixed. Each item is limited to 16 MiB and the decoded album total is limited to
64 MiB.
{
"type": "album",
"to": "628123456789@s.whatsapp.net",
"caption": "Trip photos",
"medias": [
{ "type": "image", "data": "iVBORw0KGgoAAAANSUhEUgAA...", "mimetype": "image/png" },
{ "type": "image", "url": "https://example.com/photo.jpg" }
]
}The uploaded bytes are not retained: the gateway holds them only long enough to
deliver the message (for an async send, until the queued row is drained), then
drops them. For URL sends, the outbox stores the URL for retry, not the downloaded
file. The stored message keeps the caption and media metadata, never the file
content.
A poll request looks like this:
{
"type": "poll",
"to": "628123456789@s.whatsapp.net",
"name": "Lunch?",
"options": ["Pizza", "Sushi", "Salad"],
"selectableCount": 1
}Change or reference a sent message
Once a message exists, these endpoints act on it by its message id:
| Action | Endpoint |
|---|---|
| Edit the text | edit a message |
| Delete for everyone | revoke a message |
| React with an emoji | add / remove a reaction |
| Forward it to another chat | forward a message |
| Vote on a poll | vote |
Sync vs. async
By default the send is synchronous: the call blocks until WhatsApp
acknowledges the message and its local projection is saved, then returns 200
with mode: "sync" and status: "sent".
Add ?async=true to enqueue the send instead. You get 202 with
mode: "async" and an outboxId. Use the idempotency lookup below to read
the current durable command status. message.status
events report later WhatsApp delivery receipts for
messages that were sent.
Retrying safely (idempotency)
Pass an Idempotency-Key header with a value you choose. If a request with
that key has already been accepted, the API returns its current durable
result with replayed: true and does not send again. A queued or sending
command returns 202 with mode: "async"; a sent, failed, or unknown command
returns 200 with mode: "sync" and the corresponding status. unknown
means WhatsApp may have committed the effect; do not submit it under a new key.
Read the status without submitting another send:
curl 'https://your-gateway/api/v1/sessions/{id}/outbox/by-idempotency?key=your-key' \
-H "Authorization: Bearer $TOKEN"The lookup needs send permission and access to the original session. A
missing key or a key accepted for another session returns 404. Keys are
unique within an organization, so choose a new key for each distinct action.
The lookup does not contact WhatsApp or schedule delivery.
You may add expiresAt to a send body or reaction body as an absolute UTC
Unix epoch millisecond. The first accepted deadline stays fixed if you reuse
the key, even if a later request supplies a different value. Once the deadline
passes, no new WhatsApp transport call starts. An expiry before any possible
dispatch settles failed; an expiry after dispatch may have started can settle
unknown. A validated WhatsApp acknowledgement remains sent even if the
local history projection finishes after the deadline.
Rate limits
Each session has two send budgets: a per-minute cap and a per-hour cap. The
defaults are 20 per minute and 200 per hour (configurable per session; the
server-wide defaults come from DEFAULT_RATE_PER_MIN and
DEFAULT_RATE_PER_HOUR).
What happens when you go over depends on the mode:
| Mode | Over the limit |
|---|---|
| Sync (default) | Returns 429. The error includes retryAfterSeconds. |
Async (?async=true) | The message stays queued and goes out once the budget refills. |
Sending fast or in bulk is exactly the behavior WhatsApp bans numbers for. The rate limits, plus the typing and read-receipt simulation, exist to keep traffic looking human. Keep volume low and pacing natural.
Interactive buttons and selection lists
The gateway sends buttons as native actions and list as a legacy
selection list. WhatsApp can accept a send without rendering its controls on
the recipient's client. A production linked session delivered quick-reply
buttons, but WhatsApp rejected legacy lists with server code 405. When that
happens, the API returns not_implemented and stops retrying that list. Use
buttons for interactive choices on that session.
Send one to three buttons per message. Each button title can have up to 20 Unicode characters; longer titles and a fourth button are rejected before the message reaches WhatsApp. Put longer wording in the message body.
The quick-reply request shape is:
{
"type": "buttons",
"to": "120363021234567890@g.us",
"text": "Which product did you mean?",
"buttons": [
{"id": "product-1", "title": "Jordan 1"},
{"id": "product-4", "title": "Jordan 4"}
]
}For a link or a copy action, set kind on the button. Leave it out for a
quick reply. A link requires an HTTPS URL; a copy action requires the text to
copy. URL and copy-code values are each limited to 32,768 UTF-8 bytes, the
largest payload verified in live WhatsApp tests. You can attach an image header
using headerImage.data (base64) or
headerImage.url (HTTP/S). When a footer is present, the gateway adds a
newline after the body so the two lines have more space.
{
"type": "buttons",
"to": "120363021234567890@g.us",
"text": "Try these actions",
"buttons": [
{"kind": "url", "title": "Open site", "url": "https://example.com"},
{"kind": "copy", "title": "Copy code", "code": "DEMO-123"}
]
}The selection-list request shape is:
{
"type": "list",
"to": "120363021234567890@g.us",
"text": "Choose a product",
"list": {
"title": "View products",
"sections": [{
"title": "Matches",
"rows": [
{"id": "product-1", "title": "Jordan 1", "description": "Red/black"},
{"id": "product-4", "title": "Jordan 4"}
]
}]
}
}IDs must be unique within each message and labels must be nonblank. Send product
images separately using type: image or album.
The schema accepts group JIDs for both types. Recipient rendering can vary by account and client even after WhatsApp acknowledges a send. Send product images separately as image or album messages.
Subscribe to message.interactive_reply (or *) for selections over webhooks or
realtime. Its payload includes:
{
"type": "interactive_reply",
"chatJid": "120363021234567890@g.us",
"senderJid": "628123456789@s.whatsapp.net",
"quotedMessageId": "ORIGINAL_WHATSAPP_MESSAGE_ID",
"interactiveReply": {"kind": "button", "id": "product-1", "title": "Jordan 1"}
}kind is list for list selections. Original-message and sender phone metadata
are present when WhatsApp supplies them; a participant may instead be identified
by senderLid. Match the chat, sender, and original message against your own
pending choices. Validate IDs yourself: a received selection does not prove that
the sender was authorized to choose it. Plain numeric replies remain message
events, and selections emit message.interactive_reply instead of message.