Skip to main content
Version: v0.23.1

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.

Prerequisites

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:

RecipientJID formExample
A person<phone>@c.us — full international number, digits only, no +628123456789@c.us
A group<groupId>@g.us120363021234567890@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.

Confirm a number is on WhatsApp

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.

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.

Behaviour change for unchanged integrations on Baileys

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 onlywhatsapp-web.js takes the boolean and answers 501. Do not combine it with linkPreview: false, which asks for the opposite. Since v0.23.0 send-template carries the same linkPreview field with the same engine split as send-text.

Plugins can ask for a preview too

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.

Crash-stuck sends are reaped

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 public http/https URL that OpenWA fetches server-side, or
  • base64 — raw base64 data, in which case mimetype is required.
FieldRequiredConstraints
chatIdyesRecipient JID.
urlconditionalRequired when base64 is absent. Fetched through an SSRF guard.
base64conditionalRequired when url is absent. Decoded size is checked against the media cap.
mimetypeconditionalRequired with base64 — for example image/jpeg, application/pdf, audio/ogg.
filenamenoMax 255 characters.
captionnoMax 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).

Media size cap

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.

RouteOutputUse it before
POST /api/sessions/{sessionId}/media/convert/voiceOgg/Opus audiosend-audio with ptt: true, or status/send-voice
POST /api/sessions/{sessionId}/media/convert/videoFast-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:

FieldNotes
urlPublic http/https URL, server-fetched through the SSRF guard.
base64Raw 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).

StatusCause
400 Bad RequestNeither url nor base64 given, ffmpeg refused the input, or the input URL is blocked, unreachable, or oversized.
413 Payload Too LargeThe supplied media is above the media size cap.
503 Service UnavailableConversion is disabled, ffmpeg is not runnable, or the queue is saturated — retry shortly.

Mention participants

Every text-carrying route accepts an optional mentions array of WIDs (<phone>@c.us) to tag participants — most useful in groups. That is send-text, the media routes, and (since v0.23.0) reply, edit, send-template, and each send-bulk item. 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.

On edit the tags are re-applied, not preserved: an edit replaces the message content, so a caller editing a tagged message passes mentions again or the tags are gone.

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 linkPreview or customLinkPreview — neither of which reply accepts (mentions it does take, since v0.23.0). 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-texttext
send-imagean image
send-videoa video
send-audioaudio — a quoted voice note is { chatId, url, ptt: true, quotedMessageId }
send-documenta document
send-stickera sticker
send-locationa location
send-contacta contact card
send-polla poll

send-template, send-bulk, and send-product 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 three instead of assuming it is harmless everywhere. (send-catalog accepted it equally little, but the route was removed in v0.19.0.) 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.jsBaileys
id to supplythe serialized message id (true_<chat>_<hash>)the raw message key id
where it is resolvedin the WhatsApp Web pagethe gateway's local message store
message not found404 — the send is refused404 — 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 404 on whatsapp-web.js, where it used to read as a server fault.
  • Only failures that reached WhatsApp count toward the send breaker, and a 404 is 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.
One case whatsapp-web.js still delivers unquoted

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.

Cold blasts get numbers banned

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

RouteBody
POST /api/sessions/{sessionId}/status/send-texttext (max 4096), recipients[], optional backgroundColor (#rrggbb), optional font (0–5)
POST /api/sessions/{sessionId}/status/send-imageimage (url or base64 + mimetype), recipients[], optional caption
POST /api/sessions/{sessionId}/status/send-videovideo (url or base64 + mimetype), recipients[], optional caption
POST /api/sessions/{sessionId}/status/send-voiceaudio (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:

typeMeaning
voiceA PTT voice note (matches the outbound ptt send).
pollA native WhatsApp poll (matches send-poll).
callAn incoming call; carries { video, missed } detail.
maskedA 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.
revokedA 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.

Storage cost

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.

The sweep runs even with archiving off

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

StatusCauseFix
400 Bad RequestValidation 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 UnauthorizedMissing 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 ForbiddenThe key is valid and in scope but its role is below operator.Use an operator-or-higher key.
404 Not FoundA 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 LargeDecoded media exceeds the 50 MiB cap.Compress the file or send a smaller asset.
500 Internal Server ErrorThe send failed at the WhatsApp engine.Confirm the session is connected and retry.

Next steps