Send messages from a session
Send text, images, documents, audio, locations, contact cards, polls, 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="8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a" # the UUID 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 + | 628123456789@c.us |
| A group | <groupId>@g.us | 120363021234567890@g.us |
To message +62 812-3456-789, the chatId is 628123456789@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": "628123456789@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_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
Keep messageId — you need it to react to, reply to, or forward this message later.
Link previews are opt-in on Baileys (v0.14.0+)
Since v0.14.0 the engines diverge on URL previews. On whatsapp-web.js WhatsApp Web still builds a preview by default and linkPreview: false suppresses it. On baileys previews are opt-in: the engine no longer fetches the page and generates a card unless you ask for it. Set linkPreview: true to have the gateway fetch the page and attach a preview, or leave it unset (or false) to send the URL as a plain link.
curl -X POST "$BASE/sessions/$SESSION/messages/send-text" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "628123456789@c.us",
"text": "Read the launch notes https://example.com/launch",
"linkPreview": true
}'
Generating a preview is a blocking outbound fetch per URL in the text, which is why it is never done unless asked — a bulk campaign carrying URLs paid that fetch on every send.
If you send a URL on the baileys engine and did not set linkPreview, recipients who previously saw a preview card now see a plain link. Set linkPreview: true to restore it. whatsapp-web.js sessions are unaffected.
Supply your own card with customLinkPreview instead — nothing is fetched, so you can attach a preview even for a URL this server cannot reach. It needs at least url and title (WhatsApp renders no preview without a title):
{
"chatId": "628123456789@c.us",
"text": "We just launched https://example.com/launch",
"customLinkPreview": {
"url": "https://example.com/launch",
"title": "We just launched",
"description": "Read the announcement."
}
}
customLinkPreview is Baileys only — whatsapp-web.js takes the boolean and answers 501. Do not combine it with linkPreview: false, which asks for the opposite.
A plugin relaying a URL had no way to restore the card once previews became opt-in. Since v0.14.1 the plugin send envelope carries its own linkPreview, forwarded on a plain text send.
Every outbound send is persisted as pending before it reaches the engine and resolved to sent/failed right after. A process crash between those two writes would strand the row pending forever, so a periodic reaper sweeps stale outbound pending rows, marks them failed with a reapedAt metadata marker, and re-emits the message:persisted hook — plugin-driven consumers (for example a search provider) reconcile to the terminal state. Tune it with MESSAGE_REAPER_INTERVAL_MS (default 10 minutes; 0 disables the reaper), MESSAGE_REAPER_GRACE_MS (default 1 hour — only rows older than this are reaped), and MESSAGE_REAPER_BATCH_SIZE (default 50 rows per sweep).
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. Applies to image, video, document, and sticker; 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": "628123456789@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": "628123456789@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": "628123456789@c.us",
"url": "https://example.com/report.pdf",
"filename": "report.pdf",
"mimetype": "application/pdf"
}'
Audio / voice note
Send a plain audio file by pointing url or base64 at the audio:
curl -X POST "$BASE/sessions/$SESSION/messages/send-audio" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "628123456789@c.us",
"url": "https://example.com/song.mp3",
"mimetype": "audio/mpeg"
}'
Add "ptt": true to send a real WhatsApp voice note — the microphone bubble with a
waveform — instead of a plain audio file. Voice notes require audio/ogg; codecs=opus,
so when ptt is set without a mimetype the server defaults to that. Supply OGG/Opus
bytes for reliable playback. A ptt send is stored and reported as message type voice,
matching how inbound voice notes are classified.
curl -X POST "$BASE/sessions/$SESSION/messages/send-audio" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "628123456789@c.us",
"url": "https://example.com/clip.ogg",
"ptt": true
}'
ptt is valid only on send-audio. send-video and send-sticker use the identical
body as plain audio — 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; the rejection message is generic and
does not disclose the resolved internal address.
Convert media server-side
WhatsApp is picky about formats — a voice note only plays as Ogg/Opus, a video streams cleanly only as fast-start MP4. When your source is something else, convert it on the gateway before sending instead of building an ffmpeg pipeline of your own. Conversion is opt-in and gated behind MEDIA_CONVERSION_ENABLED (default off).
First check the gateway can convert for the session — GET /api/sessions/{sessionId}/media/convert reports whether conversion is switched on and the ffmpeg binary actually runs, so you can decide between converting here and converting before you send:
curl "$BASE/sessions/$SESSION/media/convert" \
-H "X-API-Key: $API_KEY"
{ "enabled": true, "available": true }
When available is false, the convert routes answer 503 — set MEDIA_CONVERSION_ENABLED=true and, if ffmpeg is not on PATH, point FFMPEG_PATH at the binary. The official Docker image ships ffmpeg and only needs the flag.
| Route | Output | Use it before |
|---|---|---|
POST /api/sessions/{sessionId}/media/convert/voice | Ogg/Opus audio | send-audio with ptt: true, or status/send-voice |
POST /api/sessions/{sessionId}/media/convert/video | Fast-start MP4 (baseline H.264 + AAC, long edge ≤ 1280) | send-video or status/send-video |
Both take the same flat body. Give exactly one source — base64 wins if you supply both:
| Field | Notes |
|---|---|
url | Public http/https URL, server-fetched through the SSRF guard. |
base64 | Raw base64 of the media. Takes precedence when both are supplied. |
A voice-note conversion returns bytes ready to pass straight to send-audio with ptt: true:
# 1. Convert the source into Ogg/Opus.
curl -X POST "$BASE/sessions/$SESSION/media/convert/voice" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/note.m4a" }'
{ "mimetype": "audio/ogg; codecs=opus", "base64": "T2dnUwAC..." }
# 2. Send the converted bytes as a voice note.
curl -X POST "$BASE/sessions/$SESSION/messages/send-audio" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "628123456789@c.us",
"base64": "T2dnUwAC...",
"mimetype": "audio/ogg; codecs=opus",
"ptt": true
}'
Conversion is bounded so a burst cannot stack ffmpeg processes. MEDIA_CONVERSION_CONCURRENCY (default 2) caps concurrent conversions behind a short queue; beyond the queue the endpoint answers 503 — retry shortly rather than piling on. Each conversion is also killed after MEDIA_CONVERSION_TIMEOUT_MS (default 60000), and the converted bytes are capped at MEDIA_CONVERSION_MAX_OUTPUT_BYTES (default 50 MiB).
| Status | Cause |
|---|---|
400 Bad Request | Neither url nor base64 given, ffmpeg refused the input, or the input URL is blocked, unreachable, or oversized. |
413 Payload Too Large | The supplied media is above the media size cap. |
503 Service Unavailable | Conversion is disabled, ffmpeg is not runnable, or the queue is saturated — retry shortly. |
Mention participants
send-text and every media route accept an optional mentions array of WIDs
(<phone>@c.us) to tag participants — most useful in groups. Pass neutral @c.us WIDs
and the active engine de-normalizes them. For a tag to render and notify, the text or
caption must also contain the matching @<number> token:
curl -X POST "$BASE/sessions/$SESSION/messages/send-text" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "120363021234567890@g.us",
"text": "Hey @628123456789, can you check this?",
"mentions": ["628123456789@c.us"]
}'
Inbound messages that tag participants surface the tagged WIDs as mentionedIds on the
webhook payload. See the API reference for the full
field constraints.
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": "628123456789@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": "628123456789@c.us",
"contactName": "John Doe",
"contactNumber": "628987654321"
}'
Send a poll
POST /api/sessions/{sessionId}/messages/send-poll sends a native WhatsApp poll with
2–12 options. Single choice is the default; set allowMultipleAnswers to true to let
voters pick more than one. Polls work on both engines.
curl -X POST "$BASE/sessions/$SESSION/messages/send-poll" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "120363021234567890@g.us",
"name": "Where should we meet?",
"options": ["Park", "Beach", "Downtown"],
"allowMultipleAnswers": false
}'
name is the poll question (max 255 characters); each entry in options is a vote label
(max 100 characters). The poll question is stored as the message body so the history stays
readable. Like the other send routes, send-poll returns 201 with { messageId, timestamp }.
An inbound poll arrives as a message of type poll.
The JavaScript SDK does not yet wrap send-poll as a named method — call it through the
client's generic request:
const poll = await client.request({
method: 'POST',
path: '/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/messages/send-poll',
body: {
chatId: '120363021234567890@g.us',
name: 'Where should we meet?',
options: ['Park', 'Beach', 'Downtown'],
},
});
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": "628123456789@c.us",
"messageId": "true_628123456789@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 sends a text reply quoting a prior
message ({ chatId, quotedMessageId, text } — all three required, text capped at 4096
characters), and forward copies one between chats ({ fromChatId, toChatId, messageId }).
Both return 201. See the API reference.
reply is not deprecated and is not superseded. It is the text shorthand, and the only
quoting call that also resolves the quoted message's body into the stored quote preview.
Reach for quotedMessageId on a send-* route instead whenever the reply carries anything
other than plain text, or when you also need mentions, linkPreview, or
customLinkPreview — none of which reply accepts. Neither mechanism replaces the other;
both ship and both are supported. See
Quote a message on any send below.
Quote a message on any send (v0.17.0+)
Since v0.17.0 nine send routes accept an optional quotedMessageId string, which turns the
send into a reply quoting an earlier message. The field is optional on every one of them —
adding it changes nothing else about the call — and an empty string is rejected with 400.
Route (POST /api/sessions/{sessionId}/messages/…) | The reply carries |
|---|---|
send-text | text |
send-image | an image |
send-video | a video |
send-audio | audio — a quoted voice note is { chatId, url, ptt: true, quotedMessageId } |
send-document | a document |
send-sticker | a sticker |
send-location | a location |
send-contact | a contact card |
send-poll | a poll |
send-template, send-bulk, send-product, and send-catalog do not accept
quotedMessageId, and they reject it with 400 rather than ignoring it — every route
refuses body fields it does not declare. If you build send bodies from one shared template,
strip the field before it reaches those four instead of assuming it is harmless everywhere.
The four status/send-* routes do not accept it either: status posts are not quotable.
The id format differs per engine
The id is engine-specific, and the two formats are deliberately not harmonized — a body that works on one engine does not work verbatim on the other:
| whatsapp-web.js | Baileys | |
|---|---|---|
| id to supply | the serialized message id (true_<chat>_<hash>) | the raw message key id |
| where it is resolved | in the WhatsApp Web page | the gateway's local message store |
| message not found | 404 — the send is refused | 404 — the send is refused |
Baileys can only quote a message it has already stored. Neither engine checks that the quoted message belongs to the chat you are sending to — the id is passed through as given.
Reply to a message with an image:
curl -X POST "$BASE/sessions/$SESSION/messages/send-image" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "628123456789@c.us",
"url": "https://example.com/receipt.jpg",
"caption": "Here is the receipt you asked for",
"quotedMessageId": "true_628123456789@c.us_3EB0ABCD"
}'
The response is the usual 201 with { messageId, timestamp }.
An id the engine cannot resolve fails the send with 404 Not Found on both engines,
message Message <id> not found — the send is not delivered unquoted, so nothing reaches
the recipient. On whatsapp-web.js this is a change: the same request answered 500 before
v0.17.0. Two consequences for retry logic:
- A client that branches on status codes must treat an unresolvable quote as a
404on whatsapp-web.js, where it used to read as a server fault. - Only failures that reached WhatsApp count toward the send breaker,
and a
404is not one of them. Retrying a stale quote id therefore no longer accumulates toward the breaker, so a caller looping on an expired id can no longer pause every send on that session for the breaker cooldown.
If the quoted message resolves but WhatsApp Web decides it is not replyable, whatsapp-web.js
sends the message without the quote and still answers 201. That path is upstream and
cannot be switched off, so a caller that must confirm the quote landed has to check the
delivered message. Baileys has no such case.
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.
Sending the first-ever message to a batch of strangers is the top cause of WhatsApp restrictions, on either engine — pacing does not protect you. Send bulk only to recipients who opted in, and read Ban risk & safe sending before you broadcast.
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. Per-item text and caption match the single-send caps
(4096 and 1024 characters), and variables must be an object — a non-object value is
rejected with 400.
Media presence and size are re-validated per item after variable substitution and the
message:sending hook run — a {{variables}} substitution or a hook rewrite that grows a
payload past the 50 MiB media cap, or strips the last media source, fails
just that item (honoring stopOnError) instead of failing the whole batch.
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/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/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. The terminal statuses are exclusive — cancelling a batch that is
already completed, cancelled, or failed returns 400 (Batch '<id>' is already <status>), so a cancel can no longer relabel a failed batch and mask its delivery
failures. A cancellation is also final once it lands: every status transition is a guarded
database update, so a batch's later writes can't flip cancelled back — even a cancel that
arrives before the first item is sent.
Post a status update
The status endpoints post to the WhatsApp Status (Stories) feed. They are Baileys
only — a whatsapp-web.js session returns 501 Not Implemented (WA Web removed the
gating primitive these depend on).
| Route | Body |
|---|---|
POST /api/sessions/{sessionId}/status/send-text | text (max 4096), recipients[], optional backgroundColor (#rrggbb), optional font (0–5) |
POST /api/sessions/{sessionId}/status/send-image | image (url or base64 + mimetype), recipients[], optional caption |
POST /api/sessions/{sessionId}/status/send-video | video (url or base64 + mimetype), recipients[], optional caption |
POST /api/sessions/{sessionId}/status/send-voice | audio (url or base64), optional backgroundColor (#RRGGBB, Baileys only); see note below (v0.14.0+) |
recipients is required — an array of 1–256 JIDs, each <phone>@c.us or <phone>@lid.
An empty array is rejected with 400. @c.us recipients are reliable; @lid is
best-effort and unverified.
curl -X POST "$BASE/sessions/$SESSION/status/send-text" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "We are live!",
"recipients": ["628123456789@c.us", "628987654321@c.us"],
"backgroundColor": "#25D366"
}'
A voice status posts an audio clip as a playable voice bubble. WhatsApp plays a status voice note only as Ogg/Opus and neither engine transcodes, so convert the source first with /media/convert/voice. backgroundColor styles the bubble behind the voice note and is honored on Baileys only; whatsapp-web.js ignores it and broadcasts to the account's status-privacy audience instead of an explicit recipients list.
curl -X POST "$BASE/sessions/$SESSION/status/send-voice" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"audio": { "base64": "T2dnUwAC..." },
"backgroundColor": "#25D366",
"recipients": ["628123456789@c.us"]
}'
In the SDK, these live on client.status:
await client.status.sendText("8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a", {
text: "We are live!",
recipients: ["628123456789@c.us"],
});
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("8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a", {
chatId: "628123456789@c.us",
text: "Hello from the OpenWA SDK!",
});
console.log(result.messageId);
client.messages exposes sendImage, sendVideo, sendAudio, sendDocument,
sendSticker, sendLocation, sendContact, sendTemplate, reply, forward, react,
and sendBulk with the same fields as the curl bodies above. send-poll is not yet a
named method — call it through client.request as shown in Send a poll.
Status posts live on client.status (sendText, sendImage, sendVideo). See
SDK usage.
Inbound message types and omitted media
When you receive messages back over webhooks or the WebSocket, the
type field discriminates the payload. Beyond the familiar text, image, video,
audio, document, sticker, location, and contact types, watch for:
type | Meaning |
|---|---|
voice | A PTT voice note (matches the outbound ptt send). |
poll | A native WhatsApp poll (matches send-poll). |
call | An incoming call; carries { video, missed } detail. |
masked | A high-security business message (e.g. an enterprise OTP) whose body is withheld on linked devices — the body is empty by design and only readable on the primary phone. |
revoked | A message deleted "for everyone"; the payload carries an optional revokedId for the original message. |
When MEDIA_DOWNLOAD_ENABLED=false — or an inbound media item exceeds the
MEDIA_DOWNLOAD_MAX_BYTES cap — the media is not downloaded. The message still arrives,
but its media field carries an omitted marker instead of the bytes:
{ "mimetype": "image/jpeg", "omitted": true, "sizeBytes": 84213 }
Use that shape to distinguish "media was present but not downloaded" from a genuinely text-only message.
Archive chat media server-side
By default an inbound media item is downloaded once, attached inline to the message row, and then OpenWA holds no separate copy — the bytes are gone once WhatsApp's own link expires. Switch on CHAT_MEDIA_ARCHIVE_ENABLED (default off) and every message's media is also written to the file store (local or S3), so it stays retrievable long after delivery.
Fetch it back with the chat and message id:
curl "$BASE/sessions/$SESSION/messages/628123456789@c.us/true_628123456789@c.us_3EB0ABCD/media" \
-H "X-API-Key: $API_KEY" -o photo.jpg
A successful response (200 OK) streams the bytes back as an attachment: the archived file when one exists, otherwise the inline copy held on the message row — which is how media sent by this account is served. Set CHAT_MEDIA_ARCHIVE_OUTBOUND=true (default off, and it requires CHAT_MEDIA_ARCHIVE_ENABLED=true) to give outbound media the same durable file copy, S3 portability, and TTL retention that inbound media gets.
The 404 covers four cases: the message carries no media; media download was disabled or the payload was over the cap when it was stored, leaving a size-only marker; it was a URL-based API send, whose bytes are never stored; or the message is not in this gateway's history. Retention (CHAT_MEDIA_ARCHIVE_TTL_DAYS, default 0 = forever) clears the archived file, after which the inline copy still answers.
Archiving keeps the inline base64 copy on the message row and writes a file, so it roughly doubles storage for media under the cap — which is why it ships off by default. Raise the cap or TTL knowingly.
Since v0.16.0 the retention purge and the orphan sweep run while CHAT_MEDIA_ARCHIVE_ENABLED is false, which previously stopped both. If you once had archiving on and later switched it off, the sweep now deletes any file under chat-media/ that no message row references, once it has been seen unreferenced for CHAT_MEDIA_ORPHAN_GRACE_MS (default 3600000 — 1 hour); the sweep itself runs every CHAT_MEDIA_ORPHAN_SWEEP_INTERVAL_MS (default 1 hour), and the first pass after a restart only records what it sees, so nothing is deleted before a full grace window has elapsed. Only the chat-media/ prefix is swept. The TTL purge stays a no-op while CHAT_MEDIA_ARCHIVE_TTL_DAYS is 0.
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, a rendered template over the TEMPLATE_RENDER_MAX_CHARS cap (default 64 KiB), a cancel on a batch already in a terminal status, 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 | A quotedMessageId (on a send route or on reply) that the engine cannot resolve — on both engines since v0.17.0, where whatsapp-web.js previously answered 500; or 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.) | Quote an id the engine still holds, in the format that engine expects; 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.