Send messages from a session
Send text, images, documents, audio, locations, contact cards, reactions, and bulk broadcasts from any connected session over the REST API. Every send is one HTTP call addressed to a session and a recipient.
- A session in the
readystate — see Connect a Session. - An API key with the operator role or higher, sent as
X-API-Key— see Authentication.
All examples use these placeholders. Set them once in your shell:
export BASE="http://localhost:2785/api" # /api is the global prefix; behind your domain + TLS in production
export API_KEY="YOUR_API_KEY" # an operator-or-higher key, from your dashboard
export SESSION="my-session" # the id of a ready session
Address a recipient with a JID
Every send takes a chatId — a JID (Jabber ID), WhatsApp's address for a chat.
OpenWA uses two forms:
| Recipient | JID form | Example |
|---|---|---|
| A person | <phone>@c.us — full international number, digits only, no + | 6281234567890@c.us |
| A group | <groupId>@g.us | 120363021234567890@g.us |
To message +62 812-3456-7890, the chatId is 6281234567890@c.us. For the other JID
dialects and where each appears, see the Glossary.
Resolve a raw number to its canonical JID with GET /api/sessions/{sessionId}/contacts/check/{number}
before sending. See the API reference.
Send a text message
POST /api/sessions/{sessionId}/messages/send-text takes a chatId and a text body
of up to 4096 characters.
curl -X POST "$BASE/sessions/$SESSION/messages/send-text" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "chatId": "6281234567890@c.us", "text": "Hello from OpenWA!" }'
Every send route returns 201 Created with the WhatsApp message id and a timestamp
(epoch seconds, a number):
{ "messageId": "true_6281234567890@c.us_3EB0ABCD", "timestamp": 1719312000 }
Keep messageId — you need it to react to, reply to, or forward this message later.
Send media
The media routes — send-image, send-video, send-audio, send-document, and
send-sticker — all share one flat request body (SendMediaMessageDto). There is no
nested { image: { url } } wrapper. Provide exactly one media source:
url— a publichttp/httpsURL that OpenWA fetches server-side, orbase64— raw base64 data, in which casemimetypeis required.
| Field | Required | Constraints |
|---|---|---|
chatId | yes | Recipient JID. |
url | conditional | Required when base64 is absent. Fetched through an SSRF guard. |
base64 | conditional | Required when url is absent. Decoded size is checked against the media cap. |
mimetype | conditional | Required with base64 — for example image/jpeg, application/pdf, audio/ogg. |
filename | no | Max 255 characters. |
caption | no | Max 1024 characters. Ignored for audio. |
Omitting both url and base64, or sending base64 without mimetype, returns 400.
Image by URL
curl -X POST "$BASE/sessions/$SESSION/messages/send-image" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"url": "https://example.com/image.jpg",
"caption": "Check out this image!"
}'
Image by base64
curl -X POST "$BASE/sessions/$SESSION/messages/send-image" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"base64": "/9j/4AAQSkZJRg...",
"mimetype": "image/jpeg",
"filename": "photo.jpg"
}'
Document
curl -X POST "$BASE/sessions/$SESSION/messages/send-document" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"url": "https://example.com/report.pdf",
"filename": "report.pdf",
"mimetype": "application/pdf"
}'
Audio / voice note
curl -X POST "$BASE/sessions/$SESSION/messages/send-audio" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"url": "https://example.com/voice.ogg",
"mimetype": "audio/ogg"
}'
send-video and send-sticker use the identical body — point url or base64 at a
video (video/mp4) or a sticker (image/webp).
All media share one byte cap: 50 MiB (52,428,800 bytes) by default, set by the
MEDIA_DOWNLOAD_MAX_BYTES environment variable. It bounds base64 sends, remote-URL
downloads, and inbound media alike. A blob whose decoded size exceeds the cap is
rejected with 413 Payload Too Large. A remote URL pointing at an internal or blocked
address is rejected by the SSRF guard with 400.
Send a location
send-location requires latitude and longitude; description and address are optional.
curl -X POST "$BASE/sessions/$SESSION/messages/send-location" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"latitude": -6.2088,
"longitude": 106.8456,
"description": "Jakarta",
"address": "Central Jakarta"
}'
Coordinates outside the valid latitude/longitude range return 400.
Send a contact card
send-contact requires chatId, contactName, and contactNumber.
curl -X POST "$BASE/sessions/$SESSION/messages/send-contact" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"contactName": "John Doe",
"contactNumber": "628987654321"
}'
React to a message
POST /api/sessions/{sessionId}/messages/react adds or removes an emoji reaction on an
existing message. All three fields are required; send an empty emoji to remove a
reaction.
curl -X POST "$BASE/sessions/$SESSION/messages/react" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "6281234567890@c.us",
"messageId": "true_6281234567890@c.us_3EB0ABCD",
"emoji": "👍"
}'
Unlike the send routes, react returns 200 OK with a fixed body:
{ "success": true }
Reply and forward follow the same pattern: reply quotes a prior message
({ chatId, quotedMessageId, text }), and forward copies one between chats
({ fromChatId, toChatId, messageId }). Both return 201. See the API reference.
Send to many recipients
POST /api/sessions/{sessionId}/messages/send-bulk queues a batch and processes it in
the background, pacing sends to look natural. It accepts up to 100 messages and
returns immediately.
Each item carries a type (text, image, video, audio, or document) and a typed
content object. Use {{name}} placeholders in text plus a per-item variables map for
mail-merge style substitution.
curl -X POST "$BASE/sessions/$SESSION/messages/send-bulk" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{ "chatId": "628111111111@c.us", "type": "text", "content": { "text": "Hi {{name}}" }, "variables": { "name": "Alice" } },
{ "chatId": "628222222222@c.us", "type": "image", "content": { "image": { "url": "https://example.com/promo.jpg" }, "caption": "Promo" } }
],
"options": { "delayBetweenMessages": 3000, "randomizeDelay": true, "stopOnError": false }
}'
send-bulk returns 202 Accepted with a batchId and a statusUrl:
{
"batchId": "batch_a1b2c3d4",
"status": "pending",
"totalMessages": 2,
"estimatedCompletionTime": "2026-06-25T09:21:00.000Z",
"statusUrl": "/api/sessions/my-session/messages/batch/batch_a1b2c3d4"
}
options controls pacing: delayBetweenMessages (milliseconds, 1000–60000, default
3000), randomizeDelay (adds 0–2 s of jitter, default true), and stopOnError
(halt on the first failure, default false).
Track and cancel a batch
Poll GET /api/sessions/{sessionId}/messages/batch/{batchId} for progress:
{
"batchId": "batch_a1b2c3d4",
"status": "processing",
"progress": { "total": 2, "sent": 1, "failed": 0, "pending": 1, "cancelled": 0 },
"results": [
{ "chatId": "628111111111@c.us", "status": "sent" },
{ "chatId": "628222222222@c.us", "status": "pending" }
]
}
Batch status is one of pending, processing, completed, cancelled, or failed;
each result is pending, sent, failed, or cancelled. Cancel a running batch with
POST /api/sessions/{sessionId}/messages/batch/{batchId}/cancel; remaining pending items
move to cancelled.
Send from the JavaScript SDK
The @rmyndharis/openwa SDK wraps every route above. Construct the
client with the server root (no /api suffix) and your key:
import { OpenWAClient } from "@rmyndharis/openwa";
const client = new OpenWAClient({
baseUrl: "http://localhost:2785",
apiKey: process.env.OPENWA_API_KEY!,
});
const result = await client.messages.sendText("my-session", {
chatId: "6281234567890@c.us",
text: "Hello from the OpenWA SDK!",
});
console.log(result.messageId);
client.messages exposes sendImage, sendDocument, sendAudio, sendLocation,
sendContact, react, and sendBulk with the same fields as the curl bodies above. See
SDK usage.
Common errors
Errors use the standard envelope { statusCode, message, error }. On a 400 validation
failure, message is an array of field-level strings. Any body field not listed for a
route is rejected with 400 (strict validation).
| Status | Cause | Fix |
|---|---|---|
400 Bad Request | Validation failed, an unknown body field, neither url nor base64, base64 without mimetype, an SSRF-blocked URL, out-of-range coordinates, or the session has no live engine — an unknown or not-yet-started session id both return 400 (Session '...' is not active. Start the session first.), not 404. | Check the message array and the field constraints above; confirm the session id is correct and ready. |
401 Unauthorized | Missing or invalid X-API-Key, or a key used outside its allowed sessions or IPs. | Send a valid key scoped to this session. See Authentication. |
403 Forbidden | The key is valid and in scope but its role is below operator. | Use an operator-or-higher key. |
404 Not Found | The batch id on GET batch/{batchId} or POST batch/{batchId}/cancel does not exist. (A missing or not-started session id on a send route returns 400, not 404 — see the row above.) | Verify the batchId from the send-bulk response. |
413 Payload Too Large | Decoded media exceeds the 50 MiB cap. | Compress the file or send a smaller asset. |
500 Internal Server Error | The send failed at the WhatsApp engine. | Confirm the session is connected and retry. |
Next steps
- Receive replies and delivery receipts — get inbound messages and status events.
- Manage groups — create groups and manage participants, then send to a
@g.usJID. - API reference — every message endpoint with its full payload.