WhatsApp Gateway
Guides

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 like 628123456789@s.whatsapp.net for 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

typeRequired fieldsOptional fields
texttextmentions (a list of JIDs to tag), replyTo (a message id to quote)
buttonstext, buttons: [{id, title}]footer, replyTo, mentions
list (experimental)text, list: {title, sections: [{title, rows: [{id, title}]}]}row description, footer, replyTo, mentions
pollname, optionsselectableCount — how many options one voter may pick. Votes come back as poll.vote events.
locationlatitude, longitudename — a label for the pin
contactcontact—
image, video, audio, document, stickerexactly one of media.data or media.urlmedia.mimetype, media.caption (image/video/document), media.filename (document), replyTo, mentions
albummedias with 2–10 image/video itemscaption, 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. A data: URI (e.g. what a browser's FileReader.readAsDataURL produces) is accepted too; its MIME type is used when you don't set media.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:

ActionEndpoint
Edit the textedit a message
Delete for everyonerevoke a message
React with an emojiadd / remove a reaction
Forward it to another chatforward a message
Vote on a pollvote

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:

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

On this page