WhatsApp API
WhatsApp Multi-Session API — Public Reference
| Base URL | https://wa.outrnk.io |
| Auth | Authorization: Bearer <YOUR_API_KEY> — on every request |
| Content type | application/json, except POST /api/v2/media which is multipart/form-data |
This file is the single source of truth for the public API. public/api-docs.html is generated from it — never edit the HTML by hand (see INTERNAL.md).
Companion documents:
| Document | Content |
|---|---|
| QUEUE.md | Rate limits, the queue, queued vs sent, human-like delays |
| INTERNAL.md | Architecture, operations, admin API (German, operators only) |
Table of contents
- Quickstart — first message in five minutes
- How to read a response
- Two API generations: v1 and v2
- Sending
- Media
- Interaction
- Reading
- Groups
- Queue and limits
- Status and health
- Webhooks
- Error codes
- Limits, sizes and timeouts
- Current release status and known gaps
1. Quickstart — first message in five minutes
Step 1 — get your API key
Your API key is issued per WhatsApp instance by your operator in the admin panel. It looks like wapi_<NAME>_<random>. Keep it secret; it is the only credential.
export WA_URL="https://wa.outrnk.io"
export WA_API_KEY="wapi_yourinstance_xxxxxxxxxxxx" # never commit thisStep 2 — check that your instance is connected
curl -s "$WA_URL/api/status" -H "Authorization: Bearer $WA_API_KEY"{ "success": true, "data": { "status": "connected", "connected": true, "phoneNumber": "49…" } }If connected is false, scan the QR code first:
curl -s "$WA_URL/api/qr" -H "Authorization: Bearer $WA_API_KEY"The response contains data.qr as a data-URL PNG. Open it, scan it with the WhatsApp app (Linked devices → Link a device). The instance stays linked across restarts.
Step 3 — check the recipient is on WhatsApp
curl -s "$WA_URL/api/check-number/4915212345678" -H "Authorization: Bearer $WA_API_KEY"Step 4 — send your first message
curl -s -X POST "$WA_URL/api/send-message" \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"4915212345678","message":"Hello from the API"}'{ "success": true, "data": { "messageId": "true_49152…@c.us_3EB0…", "phone": "4915212345678", "status": "sent" } }That is the whole minimum. Everything below is optional.
Step 5 (recommended) — switch to v2 and use an idempotency key
curl -s -X POST "$WA_URL/api/v2/send-message" \
-H "Authorization: Bearer $WA_API_KEY" \
-H "X-Idempotency-Key: order-4711-confirmation" \
-H "Content-Type: application/json" \
-d '{"phone":"4915212345678","message":"Your order 4711 is on its way."}'{
"success": true,
"data": {
"status": "queued",
"queueState": "scheduled",
"jobId": "wq_9f2c1b7e4a0d4c8e",
"scheduledAt": "2026-09-05T21:15:33.412Z",
"etaSeconds": 42,
"queuePosition": 7,
"statusUrl": "/api/v2/messages/wq_9f2c1b7e4a0d4c8e"
}
}Read this once. WithoutX-Idempotency-Keythere is no duplicate protection. If your request times out on the network you cannot tell "never arrived" from "arrived, answer lost" — retrying may send the message twice. With the key, retrying the exact same request is always safe: you get the original job back withidempotentReplay: true.
Poll the result:
curl -s "$WA_URL/api/v2/messages/wq_9f2c1b7e4a0d4c8e" -H "Authorization: Bearer $WA_API_KEY"2. How to read a response
Every JSON response uses the same envelope.
Success
{ "success": true, "data": { } }Error
{ "success": false, "error": "human readable text", "code": "MACHINE_CODE", "data": { } }Rules that matter in practice:
| Rule | Detail |
|---|---|
Always check success, not only the HTTP status. | The six v1 routes collapse most failures into HTTP 500 with success:false. |
| v2 uses meaningful HTTP status codes. | 202 = accepted into the queue, 200 = idempotent replay of an existing job, 4xx = your request, 5xx = us. |
code is stable, error is not. | Branch on code. The error text may be reworded. |
| Unknown fields may appear at any time. | Ignore fields you do not know; we only ever add, never rename. |
3. Two API generations: v1 and v2
v1 (/api/send-message, …) | v2 (/api/v2/…) | |
|---|---|---|
| Behaviour | Synchronous — the call blocks until WhatsApp accepted the message | Asynchronous — the message is persisted and scheduled, you get a jobId |
| Response | {status:'sent', messageId} | {status:'queued', queueState, jobId, scheduledAt, etaSeconds} |
| HTTP on success | 200 | 202 (or 200 on idempotent replay) |
| Idempotency key | not supported | supported and strongly recommended |
| Rate limits enforced | yes (via the same queue) | yes |
| Message types | text, media | text, media, voice, document, sticker, video, location, contact, poll, forward |
| Status | frozen, will not change | the one to build against |
v1 is not deprecated and its response shape is frozen. Existing integrations keep working byte for byte. Everything new should use v2.
The only way v1 can behave differently than before: if your operator enables an hourly or daily limit and the queue cannot dispatch your message within 20 seconds, POST /api/send-message answers 504 QUEUE_WAIT_TIMEOUT. The message is not lost — it stays in the queue and goes out later. The response body then contains the jobId so you can follow it. See QUEUE.md.
4. Sending
4.1 Common request fields (all v2 send routes)
| Field | Type | Default | Meaning |
|---|---|---|---|
phone | string | — | Recipient in international format without +, e.g. 4915212345678. German numbers starting with 0 are corrected to 49… automatically. |
chatId | string | — | Alternative to phone. Full WhatsApp id: …@c.us, …@lid or …@g.us (group). |
idempotencyKey | string | null | 8–128 chars of [A-Za-z0-9_.:-]. May also be sent as the X-Idempotency-Key header. If both are present they must be identical. |
priority | integer | 5 | 1–9. 9 is dispatched first. |
notBefore | ISO-8601 | null | Do not send before this instant. |
ttlSeconds | integer | instance default (86400) | 60–2592000. If no slot exists before expiry the request is rejected with 422 TTL_UNREACHABLE instead of silently expiring later. |
options | object | null | Per-message WhatsApp options, see below. |
Exactly one of phone or chatId is required.
options whitelist (anything else is silently dropped):
| Option | Applies to | Meaning |
|---|---|---|
quotedMessageId | all | Reply to this message id |
mentions | all | Array of …@c.us ids to mention |
groupMentions | groups | Array of group mention objects |
linkPreview | text | false disables the link preview |
isViewOnce | media | View-once media |
sendMediaAsHd | media | Send image in HD |
parseVCards | contact | Parse vCards |
sendAudioAsVoice, sendMediaAsDocument, sendMediaAsSticker, sendVideoAsGif, stickerName, stickerAuthor, stickerCategories are set automatically by the matching route — you do not need to pass them.
4.2 Common success response (all v2 send routes)
HTTP 202 Accepted (or 200 on idempotent replay):
{
"success": true,
"data": {
"status": "queued",
"queueState": "scheduled",
"jobId": "wq_9f2c1b7e4a0d4c8e",
"messageId": null,
"idempotencyKey": "order-4711",
"idempotentReplay": false,
"scheduledAt": "2026-09-05T21:15:33.412Z",
"etaSeconds": 42,
"queuePosition": 7,
"expiresAt": "2026-09-06T21:15:33.412Z",
"statusUrl": "/api/v2/messages/wq_9f2c1b7e4a0d4c8e",
"limits": {
"maxPerHour": 40, "maxPer24h": 500,
"usedLastHour": 12, "usedLast24h": 88,
"remainingHour": 28, "remaining24h": 412,
"minDelayMs": 4000, "maxDelayMs": 12000,
"quietHours": { "start": "22:00", "end": "07:00", "tz": "Europe/Berlin" },
"window": "sliding"
}
}
}status is the field to branch on. A fresh submission is always queued.
status | Meaning |
|---|---|
queued | Accepted and safely persisted, not sent yet. This is the answer for every fresh submission (HTTP 202), whether or not a slot was already assigned. If scheduledAt is set, the send time is known; if it is null, the planner catches up within about a second. |
| anything else | You are looking at an idempotent replay (idempotentReplay: true, HTTP 200). The field then mirrors the current state of the original job and can be sending, sent, failed, unknown, cancelled or expired. Read it, do not assume sent. |
queueState is a second, informational field carrying the queue's internal planning stage: queued (no slot assigned yet) or scheduled (slot assigned). It is deliberately kept out of status because the distinction is a planning detail, not a business outcome — in both cases the message is accepted and not yet out, and scheduledAt / etaSeconds already answer when. status never carries the value scheduled. Terminal states are not flattened: a replay of an already delivered job reports status: "sent", not queued.
A fresh submission is therefore always 202 + queued/scheduled; 200 always means replay.
Some routes add warnings: ["…"] — a non-fatal note (e.g. non-OGG audio).
4.3 POST /api/send-message — text (v1, synchronous)
curl -s -X POST "$WA_URL/api/send-message" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"phone":"4915212345678","message":"Hello"}'Body: phone or chatId, plus message (string, required).
| Outcome | HTTP | Body |
|---|---|---|
| Delivered | 200 | {"success":true,"data":{"messageId":"…","phone":"…","status":"sent"}} |
| Not delivered | 500 | {"success":false,"error":"…"} |
| Not connected / restarting | 503 | {"success":false,"error":"WhatsApp not connected"} |
| Missing field | 400 | {"success":false,"error":"Phone or chatId and message required"} |
| Still queued after 20 s (only with active limits) | 504 | {"success":false,"error":"message queued but not dispatched within 20s","code":"QUEUE_WAIT_TIMEOUT","data":{"jobId":"…","scheduledAt":"…","etaSeconds":…}} |
4.4 POST /api/v2/send-message — text
curl -s -X POST "$WA_URL/api/v2/send-message" \
-H "Authorization: Bearer $WA_API_KEY" \
-H "X-Idempotency-Key: welcome-user-9912" \
-H "Content-Type: application/json" \
-d '{
"phone": "4915212345678",
"message": "Welcome! Reply STOP to opt out.",
"priority": 5,
"options": { "linkPreview": false }
}'| Field | Required | Notes |
|---|---|---|
message | yes | non-empty string |
Errors: 400 VALIDATION_FAILED, 409 IDEMPOTENCY_PAYLOAD_MISMATCH, 422 QUEUE_FULL, 422 TTL_UNREACHABLE, 503 INSTANCE_OFFLINE, 503 QUEUE_STORE_UNAVAILABLE, 503 QUEUE_PAUSED.
4.5 POST /api/send-media — image / document (v1, synchronous)
curl -s -X POST "$WA_URL/api/send-media" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"phone": "4915212345678",
"mediaUrl": "https://example.com/invoice.pdf",
"filename": "invoice.pdf",
"caption": "Your invoice"
}'Body: phone or chatId, plus either mediaUrl or mediaBase64 (+ mimetype). Optional: filename, caption (message is accepted as an alias for caption).
Response is identical to /api/send-message plus "type":"media" in data.
mediaUrl is validated against SSRF: only public HTTP/HTTPS targets, redirects re-checked on every hop, hard size ceiling of 16 MiB.
4.6 POST /api/v2/send-media — image / document / any file
curl -s -X POST "$WA_URL/api/v2/send-media" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"chatId": "4915212345678@c.us",
"mediaUrl": "https://example.com/photo.jpg",
"caption": "Here you go",
"options": { "sendMediaAsHd": true }
}'| Field | Required | Notes |
|---|---|---|
mediaUrl / mediaBase64 / mediaId | exactly one | see Media |
mimetype | required with mediaBase64 | e.g. image/jpeg |
filename | optional | shown to the recipient |
caption | optional | message is accepted as an alias |
4.7 POST /api/v2/send-voice — voice message (PTT)
curl -s -X POST "$WA_URL/api/v2/send-voice" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"phone": "4915212345678",
"mediaUrl": "https://example.com/note.ogg",
"mimetype": "audio/ogg; codecs=opus"
}'mimetype is required. Accepted: audio/ogg, audio/ogg;codecs=opus, audio/opus, audio/mpeg, audio/mp3, audio/mp4, audio/aac, audio/amr, audio/wav, audio/x-wav, audio/webm. Anything else (including video/*) → 400 VOICE_MIMETYPE_UNSUPPORTED.
We do not transcode. For a guaranteed push-to-talk bubble on every client, send OGG/Opus. Any other accepted format still goes out, but the response contains warnings: ["non-ogg audio may not render as PTT on all clients"] — on some clients it will show up as a normal audio file.
4.8 POST /api/v2/send-document — file with a filename
curl -s -X POST "$WA_URL/api/v2/send-document" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"phone": "4915212345678",
"mediaId": "md_1f2e3d4c5b6a79880011223344556677",
"filename": "contract-2026.pdf",
"caption": "Please sign"
}'filename is required here (400 VALIDATION_FAILED otherwise).
4.9 POST /api/v2/send-sticker — sticker
curl -s -X POST "$WA_URL/api/v2/send-sticker" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"phone": "4915212345678",
"mediaUrl": "https://example.com/logo.png",
"mimetype": "image/png",
"stickerName": "Brand",
"stickerAuthor": "Acme"
}'| Rule | Result |
|---|---|
mimetype starts with video/ | 400 STICKER_VIDEO_UNSUPPORTED — video stickers need ffmpeg, which is not installed on this host. This will not change. |
mimetype is neither image/* nor video/* | 400 VALIDATION_FAILED |
mimetype omitted | accepted, but the response carries warnings: ["mimetype not provided — only image/* can be converted to a sticker"] |
Image → sticker conversion happens inside the browser and works.
4.10 POST /api/v2/send-video — video or GIF
curl -s -X POST "$WA_URL/api/v2/send-video" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"phone":"4915212345678","mediaUrl":"https://example.com/clip.mp4","asGif":true,"caption":"look"}'asGif: true makes WhatsApp render it as a looping GIF.
4.11 POST /api/v2/send-location — location pin
curl -s -X POST "$WA_URL/api/v2/send-location" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"phone": "4915212345678",
"latitude": 48.137154, "longitude": 11.576124,
"name": "Marienplatz", "address": "80331 Munich", "url": "https://example.com"
}'latitude (−90…90) and longitude (−180…180) are required. name, address, url, description are optional.
4.12 POST /api/v2/send-contact — contact card(s)
curl -s -X POST "$WA_URL/api/v2/send-contact" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"phone":"4915212345678","contactIds":["4915299999999@c.us"]}'contactIds must be a non-empty array of WhatsApp ids. One entry sends a single contact card, several entries send a contact list. The ids must be known to the linked account.
4.13 POST /api/v2/send-poll — poll
curl -s -X POST "$WA_URL/api/v2/send-poll" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{
"chatId": "49152123456789-1600000000@g.us",
"name": "Lunch on Friday?",
"options": ["Italian", "Sushi", "Döner"],
"allowMultipleAnswers": false
}'options must contain 2–12 entries; outside that range WhatsApp silently drops the poll, so we reject it with 400 VALIDATION_FAILED.
Vote results arrive through the vote_update webhook event.
4.14 POST /api/v2/messages/:msgId/forward — forward a message
curl -s -X POST "$WA_URL/api/v2/messages/true_49152%40c.us_3EB0.../forward" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"toChatId":"4915299999999@c.us"}':msgId is a WhatsApp message id (not a wq_… job id). toChatId is required. Because WhatsApp does not return an id for forwarded messages, the resulting job ends as sent with deliveryConfidence: "assumed" and a synthetic messageId of the form fwd_<ts>.
4.15 POST /api/send-bulk — many texts in one call (v1, asynchronous)
curl -s -X POST "$WA_URL/api/send-bulk" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"messages":[
{"phone":"4915212345678","message":"Hi A"},
{"phone":"4915299999999","message":"Hi B"}
]}'There is no fixed cap on the number of entries — this is one of the six frozen routes and a cap would have rejected batches that work today. The real bounds are the 10 MB JSON body and QUEUE_MAX_PENDING (5000 pending jobs per instance); entries beyond that come back individually as rejected instead of taking the batch down with them.
This route has always been asynchronous; it now returns jobId, scheduledAt and etaSeconds per entry in addition to the existing fields:
{
"success": true,
"data": {
"queued": 2,
"messages": [
{ "phone": "4915212345678", "status": "queued", "jobId": "wq_…", "scheduledAt": "…", "etaSeconds": 3 },
{ "phone": "4915299999999", "status": "queued", "jobId": "wq_…", "scheduledAt": "…", "etaSeconds": 9 }
]
}
}A single rejected entry does not abort the batch — it comes back as {"phone":"…","status":"rejected","error":"…"}. Note that data.queued is the length of the messages array, not the number of accepted entries: count status === "queued" yourself if you need the accepted count.
5. Media
There are three ways to hand media over. Pick exactly one per request.
| Way | Field | Best for | Hard limit |
|---|---|---|---|
| URL | mediaUrl | the default | 16 MiB, public HTTP/HTTPS only |
| Inline base64 | mediaBase64 + mimetype | small files | 7 MiB binary |
| Upload first, reference later | mediaId | large files, repeated use | 64 MiB, kept 72 h |
Infrastructure caveat. The reverse proxy in front of this API currently has noclient_max_body_sizeoverride, so nginx's default of 1 MB applies to request bodies. In practice that capsmediaBase64at roughly 750 KB of binary and blocks large uploads with an nginx-generated413that never reaches our code. Until your operator setsclient_max_body_size 32m;, prefermediaUrlfor anything above ~700 KB.
5.1 mediaUrl — SSRF rules
The URL is validated twice: once when the request is accepted, and again at the moment the message is actually dispatched (which may be hours later, and DNS can change in between).
Rejected with 400 VALIDATION_FAILED: private/loopback/link-local addresses, non-HTTP schemes, redirects to a non-public target, more than 3 redirect hops, Content-Length above 16 MiB.
5.2 POST /api/v2/media — upload
curl -s -X POST "$WA_URL/api/v2/media" \
-H "Authorization: Bearer $WA_API_KEY" \
-F "file=@./contract.pdf" \
-F "filename=contract-2026.pdf" \
-F "mimetype=application/pdf"HTTP 201:
{
"success": true,
"data": {
"stored": true,
"mediaId": "md_1f2e3d4c5b6a79880011223344556677",
"url": "/api/v2/media/md_1f2e3d4c5b6a79880011223344556677",
"expiresAt": "2026-09-08T18:00:00.000Z",
"size": 184320
}
}Use mediaId in any send route for the next 72 hours.
| Error | Meaning |
|---|---|
400 VALIDATION_FAILED | multipart field file missing or empty |
413 PAYLOAD_TOO_LARGE | above 64 MiB (data.maxBytes tells you the ceiling) |
507 MEDIA_STORE_UNAVAILABLE | reason: "low_disk" (server has less than 5 GB free) or reason: "too_large" (above the 16 MiB per-file store ceiling) |
5.3 GET /api/v2/media/:mediaId — metadata or bytes
# metadata
curl -s "$WA_URL/api/v2/media/md_1f2e…" -H "Authorization: Bearer $WA_API_KEY"
# bytes
curl -s "$WA_URL/api/v2/media/md_1f2e…?download=1" -H "Authorization: Bearer $WA_API_KEY" -o file.pdfWith ?download=1 the response is the raw file with Content-Type, Content-Length, Content-Disposition and Cache-Control: private, max-age=300.
404 MEDIA_NOT_FOUND if the id is unknown, deleted or expired. 410 MEDIA_GONE if the index still knows it but the file is gone from disk.
5.4 DELETE /api/v2/media/:mediaId
curl -s -X DELETE "$WA_URL/api/v2/media/md_1f2e…" -H "Authorization: Bearer $WA_API_KEY"5.5 GET /api/v2/messages/:msgId/media — download incoming media
# metadata (+ store it if the instance has storeIncomingMedia enabled)
curl -s "$WA_URL/api/v2/messages/true_…%40c.us_3EB0…/media" -H "Authorization: Bearer $WA_API_KEY"
# raw bytes
curl -s "$WA_URL/api/v2/messages/true_…/media?download=1" -H "Authorization: Bearer $WA_API_KEY" -o incoming.jpgWithout ?download=1 you get {mimetype, filename, size, stored, mediaId?, url?, expiresAt?}. stored is false with reason: "store_disabled" unless your operator enabled incoming media storage for the instance.
404 NO_MEDIA if the message has no attachment.
6. Interaction
These endpoints do not produce a visible message, therefore they are not subject to the hourly/daily send limits. They have their own limiter: 60 calls per 60 seconds per instance, exceeding it returns 429 RATE_LIMITED.
All of them return {"success":true,"data":{…}} on success and 503 INSTANCE_OFFLINE when the WhatsApp client is not connected.
| Method | Path | Body | Returns |
|---|---|---|---|
POST | /api/v2/messages/:msgId/reaction | {"emoji":"👍"} | {msgId, emoji} |
DELETE | /api/v2/messages/:msgId/reaction | — | {msgId, emoji:null} |
POST | /api/v2/chats/:chatId/seen | — | {chatId, seen} |
POST | /api/v2/chats/:chatId/typing | {"durationMs":60000} | {chatId, expiresAt} |
POST | /api/v2/chats/:chatId/recording | {"durationMs":10000} | {chatId, expiresAt} |
DELETE | /api/v2/chats/:chatId/state | — | {chatId} |
PATCH | /api/v2/messages/:msgId | {"message":"corrected text","options":{…}} | {messageId} |
DELETE | /api/v2/messages/:msgId | {"everyone":true} | {msgId, everyone} |
# react
curl -s -X POST "$WA_URL/api/v2/messages/true_…/reaction" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"emoji":"👍"}'
# typing indicator for 60 s (WhatsApp only holds ~25 s, we re-fire automatically)
curl -s -X POST "$WA_URL/api/v2/chats/4915212345678@c.us/typing" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"durationMs":60000}'
# delete for everyone
curl -s -X DELETE "$WA_URL/api/v2/messages/true_…" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"everyone":true}'durationMs is capped at 300000 (5 min). Values above 25000 are kept alive by re-firing the state every 20 s until the duration is over or the client disconnects.
7. Reading
| Method | Path | Query | Returns |
|---|---|---|---|
GET | /api/v2/chats | limit (1–200, default 50), cursor (offset), archived (1/true) | {items:[chat], total, nextCursor} |
GET | /api/v2/chats/:chatId | — | chat |
GET | /api/v2/chats/:chatId/messages | limit (1–200, default 50), fromMe | {items:[message], limit, capped} |
GET | /api/v2/messages/:msgId | — | message |
GET | /api/v2/contacts/:contactId | — | contact |
GET | /api/v2/contacts/:contactId/picture | — | {contactId, pictureUrl} |
GET | /api/v2/contacts/:phone/exists | — | {phone, registered, whatsappId} |
GET | /api/check-number/:phone | — | v1 equivalent of …/exists |
curl -s "$WA_URL/api/v2/chats?limit=20" -H "Authorization: Bearer $WA_API_KEY"
curl -s "$WA_URL/api/v2/chats/4915212345678@c.us/messages?limit=50" -H "Authorization: Bearer $WA_API_KEY"
curl -s "$WA_URL/api/v2/contacts/4915212345678/exists" -H "Authorization: Bearer $WA_API_KEY"Chat object
{ "id":"4915212345678@c.us", "name":"Jane", "isGroup":false, "isReadOnly":false,
"unreadCount":2, "timestamp":1757012345, "archived":false, "pinned":false,
"isMuted":false, "muteExpiration":null }Message object
{ "id":"true_49152…@c.us_3EB0…", "chatId":"4915212345678@c.us", "from":"…", "to":"…",
"author":null, "body":"text", "type":"chat", "timestamp":1757012345678, "fromMe":false,
"hasMedia":false, "hasQuotedMsg":false, "ack":3, "isForwarded":false,
"deviceType":"android", "location":null, "vCards":[] }Do not ask for more history than you need.limitabove 200 is clamped to 200 and the whole call has a 30-second deadline. Larger fetches walk the chat backwards inside the browser and can push the instance into a memory-driven restart.capped: truein the response tells you the ceiling was hit.
8. Groups
POST /api/v2/groups creates messages for every participant and therefore goes through the queue. All other group endpoints are immediate and use the 60/min interaction limiter.
Every group endpoint verifies isGroup first and answers 400 NOT_A_GROUP otherwise.
| Method | Path | Body |
|---|---|---|
POST | /api/v2/groups | {"title":"…","participants":["…@c.us"],"options":{…}} |
POST | /api/v2/groups/:groupId/participants | {"participants":[…],"options":{…}} |
DELETE | /api/v2/groups/:groupId/participants | {"participants":[…]} |
POST | /api/v2/groups/:groupId/admins | {"participants":[…]} |
DELETE | /api/v2/groups/:groupId/admins | {"participants":[…]} |
GET | /api/v2/groups/:groupId/invite | — → {code, url} |
POST | /api/v2/groups/:groupId/invite/revoke | — → {code} |
POST | /api/v2/groups/:groupId/leave | — |
PATCH | /api/v2/groups/:groupId | {"subject":"…"} and/or {"description":"…"} |
curl -s -X POST "$WA_URL/api/v2/groups/49152…-1600000000@g.us/participants" \
-H "Authorization: Bearer $WA_API_KEY" -H "Content-Type: application/json" \
-d '{"participants":["4915299999999@c.us"]}'
curl -s "$WA_URL/api/v2/groups/49152…-1600000000@g.us/invite" \
-H "Authorization: Bearer $WA_API_KEY"POST /api/v2/groups (group creation) is not usable in the current build — see section 14.
9. Queue and limits
Full explanation in QUEUE.md. Endpoints:
| Method | Path | Purpose |
|---|---|---|
GET | /api/v2/queue | counts, next dispatch time, current limits |
GET | /api/v2/queue/items?state=scheduled&limit=50&cursor=… | list jobs |
POST | /api/v2/queue/replan | force a re-plan |
GET | /api/v2/messages/:jobId | status of one job |
GET | /api/v2/messages?idempotencyKey=order-4711 | resolve your own key to a job |
DELETE | /api/v2/messages/:jobId | cancel a job that has not started sending |
POST | /api/v2/messages/:jobId/requeue | create a new job from a failed/expired/cancelled one |
Job ids always start with wq_. That prefix is how /api/v2/messages/:id decides whether you mean a queue job or a WhatsApp message.
curl -s "$WA_URL/api/v2/queue" -H "Authorization: Bearer $WA_API_KEY"{
"success": true,
"data": {
"mode": "pg",
"limitsEnforced": true,
"degradedSince": null,
"counts": { "queued": 3, "scheduled": 12, "sending": 1, "sent24h": 88, "failed24h": 2, "unknown24h": 0 },
"nextAt": "2026-09-05T21:19:02.000Z",
"limits": { "maxPerHour": 40, "maxPer24h": 500, "usedLastHour": 12, "usedLast24h": 88,
"remainingHour": 28, "remaining24h": 412, "minDelayMs": 4000, "maxDelayMs": 12000,
"quietHours": null, "window": "sliding" }
}
}limitsEnforced is true in pg and in memory-degraded, false only in disabled. degradedSince is an ISO timestamp while the fallback is active and null otherwise. In memory-degraded the used* and remaining* fields are null — the fallback counts in process memory and cannot report the shared 24 h window.
Job status object (GET /api/v2/messages/wq_…):
{
"success": true,
"data": {
"jobId": "wq_9f2c1b7e4a0d4c8e",
"state": "sent",
"deliveryConfidence": "assumed",
"messageId": "true_49152…@c.us_3EB0…",
"resultCode": "ASSUMED_SENT",
"errorMessage": null,
"unknownReason": null,
"idempotencyKey": "order-4711",
"kind": "text",
"chatId": "4915212345678@c.us",
"phone": null,
"priority": 5,
"originRoute": "/api/v2/send-message",
"queuedAt": "…", "scheduledAt": "…", "dispatchedAt": "…", "terminalAt": "…", "expiresAt": "…",
"attempts": 1,
"etaSeconds": null
}
}state | Terminal | Meaning |
|---|---|---|
queued | no | Persisted, no slot assigned yet |
scheduled | no | Slot assigned, waiting for scheduledAt |
sending | no | Being dispatched right now |
sent | yes | Delivered. Check deliveryConfidence. |
failed | yes | Provably not sent (bad number, media download failed, validation) |
unknown | yes | Dispatched, outcome unverifiable. Never retried automatically. Needs a human. |
cancelled | yes | Cancelled before dispatch |
expired | yes | TTL elapsed before a slot came free |
deliveryConfidence | Meaning |
|---|---|
confirmed | WhatsApp returned a real message id |
assumed | The message went out but no id came back (known library behaviour). Treat as delivered. |
unknown | Outcome genuinely unknown |
There is no automatic retry, ever. POST /api/v2/messages/:jobId/requeue creates a brand new job with a new jobId (and a derived idempotency key) — the old row stays terminal. That rule is what prevents duplicate WhatsApp messages.
10. Status and health
GET /api/status
curl -s "$WA_URL/api/status" -H "Authorization: Bearer $WA_API_KEY"{
"success": true,
"data": {
"status": "connected",
"phoneNumber": "4915212345678",
"connected": true,
"browser": { "alive": true, "failStreak": 0, "lastOkAt": 1757012345678 },
"queue": {
"sent": 120, "failed": 2, "queued": 3, "pending": 16,
"scheduled": 12, "sending": 1, "inFlight": 1, "unknown": 0,
"sent24h": 88, "failed24h": 2, "unknown24h": 0,
"mode": "pg", "degraded": false, "nextAt": "2026-09-05T21:19:02.000Z",
"limits": { "…": "…" }
},
"messages": { "…": "…" }
}
}status, phoneNumber and connected are unchanged from before; browser and the extra queue fields are additive.
status values: initializing, waiting_qr, authenticated, connected, disconnected, error.
GET /api/qr
curl -s "$WA_URL/api/qr" -H "Authorization: Bearer $WA_API_KEY"Returns {status:'connected', phoneNumber} when already linked, otherwise {qr:'data:image/png;base64,…', status} or {status, message:'QR code not yet available'}.
GET /health (no authentication)
Service-level health of the gateway itself, counts only — no instance ids, no customer data:
{ "success": true, "service": "WhatsApp Multi-Session API", "version": "1.0.0",
"timestamp": "…", "instances": { "total": 5, "online": 4, "offline": 0, "starting": 1,
"degraded": 0, "quarantined": 0 }, "queue": { "degraded": 0 } }11. Webhooks
Webhooks are opt-in per instance and configured by your operator. Existing instances receive nothing new unless they explicitly subscribe.
Envelope
{
"v": 2,
"eventId": "ev_7d3a1c9e5b2f4a80",
"event": "message",
"instanceId": "8d1b7c1f-…",
"timestamp": 1757012345678,
"data": { }
}event, instanceId, timestamp and data keep their name, type and position from v1. v and eventId are additive — old receivers keep working.
Headers
| Header | Value |
|---|---|
X-Instance-ID | instance uuid |
X-Event-Type | event name |
X-Event-Id | ev_… — use this to deduplicate |
X-Event-Version | 2 |
X-Webhook-Signature | sha256=<hex> — HMAC-SHA256 of the exact request body |
X-Webhook-Timestamp | unix ms, same value as timestamp |
X-Webhook-Signature-V2 | t=<ts>,v1=<hex> — HMAC-SHA256 of "<ts>.<body>", gives replay protection |
Verify against the raw bytes you received, not a re-serialised object.
const crypto = require('crypto');
function verify(rawBody, headers, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headers['x-webhook-signature']));
}Events
Default subscription for existing instances: message, ready, disconnected, instance_restarting, test. Everything else must be opted in. * subscribes to all.
WhatsApp events
| Event | Payload |
|---|---|
message | full message object incl. media (see below) |
message_sent | {id, to, body, timestamp} (own outgoing messages) |
message_ack | delivery/read receipts |
message_reaction | reaction added/removed |
message_edit | {id, chatId, newBody, prevBody, timestamp} |
message_revoke_everyone | {id, chatId, revokedBody, author} |
message_revoke_me | {id, chatId} |
message_ciphertext / message_ciphertext_failed | {id, chatId} — content not decryptable |
media_uploaded | {id, chatId, type} |
unread_count | {chatId, unreadCount} |
vote_update | {pollId, voterId, selectedOptions, timestamp} |
contact_changed | {messageId, oldId, newId, isContact} |
chat_removed | {chatId, name} |
chat_archived | {chatId, currState, prevState} |
group_join / group_leave / group_admin_changed | {chatId, author, recipientIds, timestamp} |
group_membership_request | {chatId, author, timestamp} |
group_update | {chatId, type, body, author} |
call | {callId, from, timestamp, isVideo, isGroup, fromMe} |
change_battery | {battery, plugged} |
code | {code} — pairing code. A login secret. Never log it. |
qr, ready, authenticated, auth_failure, disconnected, loading_screen, change_state | lifecycle |
Queue events
| Event | Payload |
|---|---|
message.queued | {jobId, idempotencyKey, chatId, scheduledAt, etaSeconds, queuePosition} |
message.sent | {jobId, messageId, deliveryConfidence, dispatchedAt} |
message.failed | {jobId, resultCode, errorMessage} |
message.unknown | {jobId, unknownReason, dispatchedAt} — needs human follow-up |
message.cancelled / message.expired | {jobId, reason} |
queue.replanned | {affected, epoch, reason} |
queue.degraded | {queueMode, limitsEnforced} — limitsEnforced is always true: the fallback runs the same gate. No error text is included on purpose — a database error message can carry role name, host and port. |
queue.recovered | {queueMode} |
There is no message.scheduled event: acceptance always fires message.queued, whether or not a slot was assigned at that moment. The message.* events above are emitted by the persistent queue path only — while an instance is in memory-degraded none of them fire, only queue.degraded at the start of the window and queue.recovered at its end.
Incoming message media block (inside data.media):
{ "mimetype": "image/jpeg", "filename": null, "size": 184320,
"stored": true, "mediaId": "md_…", "url": "/api/v2/media/md_…", "expiresAt": "…" }mimetype, filename and size are unchanged. stored, mediaId, url and expiresAt are additive and only present when your operator enabled incoming media storage.
Retries
Normal events: 3 attempts. High-frequency events (message_ack, message_sent, message_reaction, unread_count, media_uploaded, message_ciphertext, message_ciphertext_failed, change_battery): 1 attempt, no retry.
12. Error codes
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 400 | VALIDATION_FAILED | A field is missing or malformed. data.field names it. | Fix the request. |
| 400 | IDEMPOTENCY_KEY_CONFLICT | Header and body carry different idempotency keys. | Send only one. |
| 400 | VOICE_MIMETYPE_UNSUPPORTED | mimetype cannot be a voice note. | Use OGG/Opus. |
| 400 | STICKER_VIDEO_UNSUPPORTED | Video stickers need ffmpeg — not available. | Send an image. |
| 400 | NOT_A_GROUP | The chat id is not a group. | Check the id. |
| 401 | UNAUTHORIZED | Authorization: Bearer … header missing or malformed. | Add it. |
| 401 | INVALID_API_KEY | Key unknown. | Check the key. |
| 404 | JOB_NOT_FOUND / MESSAGE_NOT_FOUND / CHAT_NOT_FOUND / MEDIA_NOT_FOUND / NO_MEDIA | Target does not exist (any more). | — |
| 409 | IDEMPOTENCY_PAYLOAD_MISMATCH | Same key, different payload. data.jobId is the original. | Use a new key, or resend the identical payload. |
| 410 | MEDIA_GONE | Metadata exists, file was removed from disk. | Re-upload. |
| 413 | PAYLOAD_TOO_LARGE | Body/file above the limit. data.maxBytes is the ceiling. | Use mediaUrl or the upload endpoint. |
| 422 | QUEUE_FULL | More than 5000 pending jobs for this instance. | Back off, drain the queue. |
| 422 | TTL_UNREACHABLE | With the current limits no slot exists before expiresAt. | Raise ttlSeconds or the limits. |
| 429 | RATE_LIMITED | More than 60 interaction calls in 60 s. | Back off. |
| 500 | WA_CALL_FAILED | The WhatsApp call itself failed. | Retry later. |
| 502 | INSTANCE_UNREACHABLE | Gateway could not reach the instance process. | Message was not accepted. Safe to retry. |
| 503 | INSTANCE_OFFLINE | WhatsApp is not connected. data.instance has the details. | Wait / re-link. |
| 503 | QUEUE_STORE_UNAVAILABLE | Persistent queue storage not reachable, or the queue is not enabled for this instance. data.queueMode tells you which. | Use v1 routes, or ask your operator to enable the queue. |
| 503 | QUEUE_PAUSED | Instance is performing an ordered restart. Applies to /api/v2/* only — the v1 routes are accepted and simply wait. | Retry in ~60 s (ORDERED_RESTART_HARD_MS is the hard ceiling). |
| 504 | QUEUE_WAIT_TIMEOUT | v1 route waited 20 s, message still queued. Not lost. data.jobId follows it. | Poll GET /api/v2/messages/<jobId>. |
| 504 | INSTANCE_TIMEOUT | Gateway reached the instance but got no answer in time. | The message may have been accepted. Only retry with an idempotency key. |
| 507 | MEDIA_STORE_UNAVAILABLE | Disk full or file above the store ceiling. | Use mediaUrl. |
13. Limits, sizes and timeouts
| Item | Value | Configurable by operator |
|---|---|---|
| Media via URL | 16 MiB | MEDIA_MAX_BYTES |
| Media inline base64 | 7 MiB binary (nginx caps it at ~750 KB today) | MEDIA_BASE64_MAX_BYTES |
| Media upload | 64 MiB | MEDIA_UPLOAD_MAX_BYTES |
| Uploaded/stored media retention | 72 h | MEDIA_RETENTION_HOURS |
| Per-instance media store | 2048 MB, oldest evicted first | MEDIA_STORE_MAX_MB |
| Bulk entries per call | no fixed cap — bounded by the 10 MB JSON body and by QUEUE_MAX_PENDING | — |
fetchMessages limit | 200, 30 s deadline | FETCH_MESSAGES_MAX |
| Interaction rate limit | 60 per 60 s per instance | INTERACTION_RATE_POINTS |
| Pending jobs per instance | 5000 | QUEUE_MAX_PENDING |
| v1 synchronous wait | 20 s, hard-capped at LEGACY_CALLER_TIMEOUT_MS − 5 s (25 s today) | LEGACY_SYNC_TIMEOUT_MS |
| Idempotency key retention | 7 days | — |
| Idempotency key format | 8–128 chars, [A-Za-z0-9_.:-] | — |
| Gateway timeout, send routes | 60 s | — |
| Gateway timeout, read/interaction | 30 s | — |
| Gateway timeout, queue status | 10 s | — |
| Gateway timeout, upload | 120 s | — |
14. Current release status and known gaps
Documented against the code as it stands, not against the plan. Nothing here is hidden.
Not implemented, and will not be
| Feature | Reason |
|---|---|
| Buttons, list messages | Disabled by WhatsApp server-side. The library logs a deprecation warning and they do not render. |
| Video/GIF → sticker | Requires ffmpeg; not installed on this host and not planned (≈250 MB plus CPU load on a box already running five browsers). 400 STICKER_VIDEO_UNSUPPORTED. |
| Audio transcoding for PTT | Same reason. Send OGG/Opus. |
| Channels / newsletters | Untested library surface, separate id namespace. |
| Status broadcast | The library rejects most content types for status and returns null. |
| History sync | Pulls unbounded history into the browser heap and reliably triggers a memory restart. Explicitly disabled. |
Physically impossible, stated honestly
Without X-Idempotency-Key, loss-free delivery across the network boundary cannot be guaranteed. If your connection drops before our commit, you cannot distinguish "never arrived" from "arrived, answer lost". A retry may duplicate. This is physics, not laziness. Use an idempotency key on every send that matters.
A unknown job state means the message was dispatched but the outcome is unverifiable. It is never retried automatically, because any automatic decision is wrong in one direction: retrying duplicates, dropping loses. Watch the message.unknown webhook and resolve it by hand.
Open defect in the current build
| Affected | Symptom |
|---|---|
POST /api/v2/queue/replan | Accepted, but performs no work — it answers {"replanned": false} and the schedule is unchanged. A re-plan does happen automatically whenever the limits are actually changed, so this endpoint is redundant rather than dangerous. |
Nothing else on this page is known to be broken. The queue is nevertheless still being rolled out per instance — see the next paragraph for how to tell whether it is on for yours.
Enabled state matters
/api/v2/send-* requires the persistent queue to be enabled for your instance. If it is not, every v2 send route answers:
{ "success": false, "code": "QUEUE_STORE_UNAVAILABLE",
"error": "queue store unavailable — loss-free delivery cannot be guaranteed",
"data": { "queueMode": "disabled" } }The v1 routes (/api/send-message, /api/send-media, /api/send-bulk) work in every mode and behave exactly as they always did. Ask your operator to enable the queue for your instance if you want v2.
Queue and Rate Limits — How Sending Really Works
Plain-language companion to API.md. Read this if you want to know when your message actually goes out, what queued means, and what happens when a limit is reached.
Table of contents
- The one-paragraph version
- The three knobs
- What "queued" means in the response
- When exactly does my message go out?
- Worked examples
- What the legacy routes do when a limit is active
- Changing limits while the queue is full
- Restarts, crashes and "nothing gets lost"
- Job states, end to end
- How to configure the limits
- Monitoring the queue
- Honest limitations
1. The one-paragraph version
You tell us how many messages an instance may send per hour and per 24 hours, and how much random pause to leave between two messages. Everything you hand in is written to a PostgreSQL table first and only then scheduled. If you are within the limits it goes out almost immediately (after the random pause). If you are over the limit it is not rejected — it gets a concrete future send time and the API tells you when. Nothing is dropped, nothing is sent twice, and no message is ever retried automatically.
2. The three knobs
| Setting | Meaning | Default (existing instances) | Default (new instances) |
|---|---|---|---|
maxPerHour | Upper bound over a sliding 60-minute window. null = unlimited. | null | 40 |
maxPer24h | Upper bound over a sliding 24-hour window. null = unlimited. | null | 500 |
minDelayMs / maxDelayMs | Random pause between two messages. Always applies, even when the queue is empty. | 1500 / 3000 | 4000 / 12000 |
quietHours | No sending during this local window, e.g. {"start":"22:00","end":"07:00","tz":"Europe/Berlin"}. null = always allowed. | null | 22:00–07:00 Europe/Berlin |
defaultTtlSeconds | How long an unsent message stays valid. | 86400 (24 h) | 86400 |
legacyQueueMode | sync = the old routes keep blocking until sent; async = they answer queued immediately. | sync | sync |
enabled | Whether the persistent queue is used at all. | false | true |
storeIncomingMedia | Keep incoming attachments on disk for 72 h. | false | false |
Existing instances are not throttled by the update. All limits start at null, which means "no limit" — exactly like before. Nothing changes until someone deliberately turns limits on.
Why the windows slide
A calendar-hour window would let you send 59 messages at 10:59 and 60 more at 11:00 — twice the limit inside two minutes. That burst is exactly the pattern WhatsApp's spam detection reacts to. A sliding window counts "the last 60 minutes from right now", so the burst is impossible.
Why the random delay always applies
The pause is measured against the last actual dispatch, not against the queue length. So even a single message sent into an empty queue waits minDelayMs…maxDelayMs after the previous one. The last dispatch time is stored in the database, so a process restart does not reset your sending rhythm — otherwise a restart would produce a suspicious burst.
3. What "queued" means in the response
A fresh submission always answers 202 Accepted with status: "queued". There is no second value to handle — status never carries scheduled.
| Field | It means | What to do |
|---|---|---|
status: "queued" | Accepted and safely persisted, not sent yet. | Nothing. It will go out. |
queueState: "scheduled" | Informational: the planner has assigned a slot. scheduledAt (ISO timestamp) and etaSeconds are filled in. | Nothing. |
queueState: "queued" | Informational: the planner had not assigned a slot at the moment we answered. scheduledAt is null. | Nothing — the planner catches up within about a second. Poll statusUrl if you want the time. |
Why two fields: whether a slot is already assigned is a planning stage, not a business outcome. In both cases the message is accepted and not yet out, and scheduledAt / etaSeconds already tell you when. Branch on status; read queueState only if you are debugging the planner.
An idempotent replay — same X-Idempotency-Key, same payload — answers 200 OK with idempotentReplay: true and returns the current state of the original job. Terminal states are not flattened into queued, so status there can be sending, sent, failed, unknown, cancelled or expired. Read status, do not assume sent. (Same key with a different payload is 409 IDEMPOTENCY_PAYLOAD_MISMATCH.)
{
"success": true,
"data": {
"status": "queued",
"queueState": "scheduled",
"jobId": "wq_9f2c1b7e4a0d4c8e",
"messageId": null,
"idempotencyKey": "order-4711",
"idempotentReplay": false,
"scheduledAt": "2026-09-05T21:15:33.412Z",
"etaSeconds": 42,
"queuePosition": 7,
"expiresAt": "2026-09-06T21:15:33.412Z",
"statusUrl": "/api/v2/messages/wq_9f2c1b7e4a0d4c8e",
"limits": {
"maxPerHour": 40, "usedLastHour": 12, "remainingHour": 28,
"maxPer24h": 500, "usedLast24h": 88, "remaining24h": 412,
"minDelayMs": 4000, "maxDelayMs": 12000, "quietHours": null, "window": "sliding"
}
}
}messageId is null until the message actually goes out; on a replay of a finished job it carries the WhatsApp id. Routes that validate more than the queue does (media, stickers, voice) may add a warnings array.
Read etaSeconds if you want to tell a user "in about a minute", read scheduledAt if you want an exact time, read limits.remainingHour if you want to decide whether to hand in more work.
4. When exactly does my message go out?
The planner walks the waiting jobs in the order priority DESC, arrival time ASC and gives each one a concrete timestamp, using this rule chain:
start at: max( now , lastDispatchAt + random(minDelayMs, maxDelayMs) )
then, until all constraints hold (max 200 attempts):
if inside quiet hours -> jump to the end of the quiet window
if hourly limit reached at t -> jump to (oldest message in the last hour) + 1h + 1ms
if 24h limit reached at t -> jump to (oldest message in the last 24h) + 24h + 1ms
place the message at t
next cursor = t + random(minDelayMs, maxDelayMs)Two things follow from that:
- The random delay is a lower bound, the limits are upper bounds. Whichever is later wins.
- Order is never changed by a limit. A limit moves when things go out, never *what goes
first*. Only priority does that.
If the computed time would fall after the message's expiresAt, the request is rejected right away with 422 TTL_UNREACHABLE instead of accepting something that will silently expire later.
Messages that count against the limits: everything in state sent, sending or unknown. unknown counts too — an unverified message was very probably delivered, and not counting it would push you over the real limit.
The plan is checked again immediately before sending
scheduledAt is a plan, not enforcement. Anything that makes the plan stale — a WhatsApp disconnect (routine with this stack), an ordered restart, a limit change, a clock change, a recovery after a crash — makes many slots fall due at once. Without a second check the dispatcher would fire them back to back, which is exactly the burst pattern WhatsApp's spam detection reacts to.
So every job is re-checked the moment before it is handed to WhatsApp: hourly budget free? daily budget free? minimum spacing respected? outside quiet hours? If any of those fails, the job goes back into the queue with a new scheduledAt and is not sent. The counters come from the same rows that record the dispatch, so they cannot drift.
Practical consequence: a backlog that piled up during a two-hour outage is drained at the configured rate, not in one burst — and etaSeconds you fetched before the outage may be outdated. Re-read the job status if the exact time matters.
5. Worked examples
Example A — no limits (the default today)
Config: maxPerHour: null, maxPer24h: null, minDelayMs: 1500, maxDelayMs: 3000.
You send 5 messages back to back. They go out roughly 1.5–3 seconds apart. etaSeconds is 0–2 for the first, growing for the rest. This is byte-for-byte the behaviour that existed before the queue was introduced.
Example B — 40 per hour, 4–12 s apart
Config: maxPerHour: 40, minDelayMs: 4000, maxDelayMs: 12000.
You hand in 100 messages at 09:00.
| Message | Scheduled | Why |
|---|---|---|
| 1–40 | 09:00:00 → ~09:05:20 | random 4–12 s apart, average ~8 s ⇒ 40 messages in ≈5.3 minutes |
| 41 | 10:00:00.001 | Hourly window is full. The oldest of the last 40 was at 09:00:00, so a slot frees at 09:00:00 + 1 h + 1 ms |
| 42 | ~10:00:08 | +random delay |
| … | … | The pattern repeats: 40 per hour, spread by the random delay |
| 100 | ~11:00:xx |
Response for message 41:
{ "status":"scheduled", "jobId":"wq_…", "scheduledAt":"2026-09-05T10:00:00.001Z",
"etaSeconds": 3600, "queuePosition": 41,
"limits": { "maxPerHour":40, "usedLastHour":40, "remainingHour":0 } }Example C — quiet hours
Config: quietHours: {"start":"22:00","end":"07:00","tz":"Europe/Berlin"}.
You hand in a message at 23:30 Berlin time. It is scheduled for 07:00:00 next morning, local time, DST-aware. etaSeconds is about 27000. Nothing is dropped.
Example D — over the daily limit
Config: maxPer24h: 500, ttlSeconds: 86400 (the default).
You hand in message number 501 for the day. The planner finds that the earliest free slot is 25 hours away, which is beyond the 24-hour TTL:
{ "success": false, "code": "TTL_UNREACHABLE",
"error": "no slot available before expiry",
"data": { "expiresAt": "2026-09-06T09:00:00.000Z" } }That is deliberate: better an immediate, honest rejection than accepting a message that will quietly expire tomorrow. Raise ttlSeconds (up to 30 days) if you want it to wait.
Example E — retrying safely
# first attempt — network dies, you never see the answer
curl -X POST "$WA_URL/api/v2/send-message" \
-H "Authorization: Bearer $WA_API_KEY" \
-H "X-Idempotency-Key: invoice-2026-0042" \
-d '{"phone":"4915212345678","message":"Invoice 42 attached"}'
# retry the identical request — safe
curl -X POST "$WA_URL/api/v2/send-message" \
-H "Authorization: Bearer $WA_API_KEY" \
-H "X-Idempotency-Key: invoice-2026-0042" \
-d '{"phone":"4915212345678","message":"Invoice 42 attached"}'The second call returns HTTP 200 with idempotentReplay: true and the original jobId. No second message is created.
If you reuse the same key with a different body you get 409 IDEMPOTENCY_PAYLOAD_MISMATCH — that guard exists so a client bug cannot get the wrong message acknowledged as "already sent".
Idempotency keys are scoped per instance and remembered for 7 days.
6. What the legacy routes do when a limit is active
POST /api/send-message and POST /api/send-media are synchronous by contract. They now go through the same queue with priority 9 and then wait for the outcome:
| Situation | HTTP | Body |
|---|---|---|
| Dispatched within 20 s | 200 | {"success":true,"data":{"messageId":"…","phone":"…","status":"sent"}} — unchanged |
| Not dispatched, failed | 500 | {"success":false,"error":"…"} — unchanged |
| Still waiting after 20 s | 504 | {"success":false,"error":"message queued but not dispatched within 20s","code":"QUEUE_WAIT_TIMEOUT","data":{"jobId":"wq_…","scheduledAt":"…","etaSeconds":3540}} |
The 20 s are LEGACY_SYNC_TIMEOUT_MS. The number in the error text is derived from that setting, so it changes with it. It is deliberately below the 30 s your client is assumed to wait (LEGACY_CALLER_TIMEOUT_MS) and is hard-capped at LEGACY_CALLER_TIMEOUT_MS − 5 s: a caller that gives up before we answer cannot tell "sent" from "not sent" and tends to retry — which is exactly how the historical duplicate incident happened.
The 504 is not a failure. The message is in the queue and will go out at scheduledAt. Follow it with GET /api/v2/messages/<jobId>. Do not resend — that would duplicate.
With maxPerHour: null (the default for all existing instances) the 504 path is unreachable: the planner produces exactly the same spacing the old in-memory queue produced, so nothing waits longer than it did before.
If you would rather have the old routes answer immediately, ask your operator to set legacyQueueMode: "async" for the instance. Then POST /api/send-message answers 202 with:
{ "success": true, "data": { "messageId": null, "phone": "…", "status": "queued",
"jobId": "wq_…", "scheduledAt": "…", "etaSeconds": 42 } }This is opt-in per instance and never happens by itself.
POST /api/send-bulk was already asynchronous and is unchanged; it just gained jobId, scheduledAt and etaSeconds per entry.
7. Changing limits while the queue is full
Changing maxPerHour, maxPer24h, minDelayMs, maxDelayMs or quietHours triggers a full re-plan:
- Everything in state
scheduledgoes back toqueued. - A message currently in state
sendingis never interrupted — it runs to completion. - The planner assigns fresh times, in the same order as before (
priority, then arrival). - A
queue.replannedwebhook fires with{affected, epoch, reason}.
Lower the limit → everything shifts later. Raise the limit → everything shifts earlier, but never earlier than max(now, lastDispatch + minDelay).
Changing storeIncomingMedia or legacyQueueMode does not trigger a re-plan — those do not affect timing.
8. Restarts, crashes and "nothing gets lost"
Every message is written to PostgreSQL before anything is sent, and the state change to sending is committed before the WhatsApp call is made. That gives a clean answer for every possible crash point:
| Crash happens | Stored state | Consequence |
|---|---|---|
| Before we committed your request | no row at all | Nothing was sent. Your client sees a network error. Retry with the same idempotency key ⇒ exactly one message. |
| After acceptance, before dispatch | queued / scheduled | Nothing lost. On restart every scheduled row is re-planned (the old plan is stale after downtime) and goes out. |
| During the WhatsApp call | sending | On restart the row becomes unknown with unknownReason: "process_crash_while_sending" and a message.unknown webhook. It is never re-sent. |
| After the WhatsApp call, before we recorded it | sending | Same as above. The WhatsApp message id was written to the process log line DISPATCH_ID job=… wa=… chat=… before the database update, so a human can reconcile it. |
Why unknown is never retried
Retrying a message that was in fact delivered produces a duplicate on a customer's phone. Dropping a message that was in fact not delivered loses it. Both are wrong, and the system cannot tell them apart. So it does neither: it marks the job unknown, fires a webhook, logs an error, and asks a human. That is the single rule that closed the historical duplicate-message incident.
If the database goes away while we are running
The send path does not stop. The instance switches to memory-degraded, keeps serving the v1 routes through the in-memory path (with limits still enforced), fires a queue.degraded webhook and probes every 30 seconds. When PostgreSQL comes back, the in-memory backlog is drained first, then normal operation resumes and queue.recovered fires. The message that was in flight at the moment of the switch stays with the fallback — adopting it would risk a duplicate.
The v2 send routes deliberately answer 503 QUEUE_STORE_UNAVAILABLE during that window: they promise loss-free delivery, and in memory that promise does not hold.
An ordered restart leaves nothing behind
When the instance restarts itself in a controlled way, it pauses the dispatcher and waits up to 35 seconds for the in-flight message to finish before shutting down. A clean restart therefore produces no unknown rows at all.
9. Job states, end to end
submit()
│
▼
┌────────┐ planner ┌───────────┐ scheduledAt reached ┌─────────┐
│ queued │─────────────►│ scheduled │────────────────────────►│ sending │
└────────┘◄─────────────└───────────┘ └─────────┘
│ re-plan │ ╱ │ ╲
│ │ transient ╱ │ ╲
│ TTL │ TTL (not yet sent)╱ │ ╲
▼ ▼ ┌──────╱ ┌────▼───┐ ╲────────┐
┌─────────┐ ┌─────────┐ │queued│ │ sent │ │unknown │
│ expired │ │ expired │ └──────┘ └────────┘ └────────┘
└─────────┘ └─────────┘ │
┌────▼───┐
cancel() from queued/scheduled ─────► cancelled │ failed │
└────────┘| State | Terminal | Meaning |
|---|---|---|
queued | no | Persisted, waiting for a slot |
scheduled | no | Slot assigned, waiting for the clock |
sending | no | Being dispatched right now — cannot be cancelled |
sent | yes | Delivered. deliveryConfidence is confirmed (real WhatsApp id) or assumed (delivered, id not returned) |
failed | yes | Provably not sent: number not on WhatsApp, media download failed, SSRF rejection, invalid payload |
unknown | yes | Dispatched, outcome unverifiable. Needs a human. |
cancelled | yes | Cancelled via DELETE /api/v2/messages/:jobId before dispatch |
expired | yes | TTL elapsed before a slot was free |
There is exactly one attempt per row. attempts in the status response is always 1. POST /api/v2/messages/:jobId/requeue does not resurrect a row — it creates a new job with a new jobId and a derived idempotency key. Only failed, expired and cancelled jobs can be requeued.
The one exception: if a message could provably not be sent yet (WhatsApp temporarily disconnected, browser page died during number validation), the row goes back to queued with a growing backoff (5 s, 10 s, … capped at 60 s), at most 10 times. After that it becomes failed with resultCode: "REQUEUE_EXHAUSTED". This only ever happens before the WhatsApp call was made, so it can never duplicate.
10. How to configure the limits
Limits live per instance and are set by your operator, either in the admin panel ("Limits" button on the instance card) or via the admin API:
curl -s -X PUT "https://wa.outrnk.io/api/instances/<INSTANCE_ID>/limits" \
-H "Authorization: Bearer <ADMIN_JWT>" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"maxPerHour": 40,
"maxPer24h": 500,
"minDelayMs": 4000,
"maxDelayMs": 12000,
"quietHours": { "start": "22:00", "end": "07:00", "tz": "Europe/Berlin" },
"legacyQueueMode": "sync",
"defaultTtlSeconds": 86400,
"storeIncomingMedia": false
}'The configuration is written to disk first and pushed to the running instance second, so a failed live push still leaves the setting correct for the next start.
Validation rules
| Field | Rule | Error text |
|---|---|---|
enabled | boolean | queue.enabled must be a boolean |
maxPerHour | null or integer 1…1000 | queue.maxPerHour must be null or an integer 1..1000 |
maxPer24h | null or integer 1…20000 | queue.maxPer24h must be null or an integer 1..20000 |
maxPer24h vs maxPerHour | must be ≥ when both are set | queue.maxPer24h must be >= queue.maxPerHour |
minDelayMs | integer 0…600000 | queue.minDelayMs must be an integer 0..600000 |
maxDelayMs | integer 0…3600000 and ≥ minDelayMs | queue.maxDelayMs must be >= queue.minDelayMs |
quietHours | null or {start,end,tz} with HH:MM and a valid IANA zone | queue.quietHours.tz is not a valid IANA timezone |
quietHours.start vs end | must differ | queue.quietHours.start and end must differ |
legacyQueueMode | sync or async | queue.legacyQueueMode must be 'sync' or 'async' |
defaultTtlSeconds | integer 60…2592000 | queue.defaultTtlSeconds must be an integer 60..2592000 |
storeIncomingMedia | boolean | queue.storeIncomingMedia must be a boolean |
Unknown keys are silently ignored (forward compatibility), so sending a newer config to an older build does not fail.
Recommended starting point
40 / 500 / 4000 / 12000 / quiet 22:00–07:00 Europe/Berlin — the values behind the "Apply recommended limits" button in the panel. They approximate a human sending pattern and stay well inside what WhatsApp tolerates for a normal account.
11. Monitoring the queue
curl -s "$WA_URL/api/v2/queue" -H "Authorization: Bearer $WA_API_KEY"{ "success": true, "data": {
"mode": "pg",
"limitsEnforced": true,
"degradedSince": null,
"counts": { "queued": 3, "scheduled": 12, "sending": 1, "sent24h": 88, "failed24h": 2, "unknown24h": 0 },
"nextAt": "2026-09-05T21:19:02.000Z",
"limits": { "maxPerHour": 40, "usedLastHour": 12, "remainingHour": 28, "window": "sliding" }
}}limitsEnforced answers the only question that matters when mode is not pg: are you still being throttled? It is true in pg and in memory-degraded, false only in disabled. degradedSince is set while the fallback is active.
mode | Persistent? | Limits enforced? | v1 routes | v2 send routes |
|---|---|---|---|---|
pg | yes | yes | normal | normal |
memory-degraded | no | yes — the same gate runs in the fallback | normal, nothing regresses | 503 QUEUE_STORE_UNAVAILABLE |
disabled | no | no — the queue is switched off for this instance | exactly like before the queue existed | 503 QUEUE_STORE_UNAVAILABLE |
unavailable | no | no | like disabled | 503 |
memory-degraded means the database was unreachable. It is entered at start-up and at runtime: if PostgreSQL disappears while the instance is running, the send path switches over instead of failing, fires a queue.degraded webhook, and probes every 30 seconds to come back. When it comes back, the in-memory backlog is drained first and a queue.recovered webhook fires.
The one thing that does change in memory-degraded: the throttle counter is process-local. It starts at zero the moment the switch happens, so the last hour of already-sent messages no longer counts against maxPerHour. In the worst case an instance can use its hourly allowance a second time right after switching. The limits are never applied more strictly than configured, only more generously — and usedLastHour / remainingHour come back as null in this mode because the shared 24 h window is not readable.
Per-message queue webhooks stop while memory-degraded is active. message.queued, message.sent, message.failed and message.unknown are emitted by the persistent path only; the fallback sends none of them. queue.degraded and queue.recovered still fire and bracket the window exactly. If you drive business logic off message.sent, treat the span between those two events as a gap and reconcile it from GET /api/v2/queue/items once mode is pg again. The messages themselves are still sent — only the notifications are missing.
/api/status carries a plain boolean for monitoring: data.queue.degraded is true for anything that is not pg — including the default mode disabled. If you want to tell the two apart, read mode and limitsEnforced from GET /api/v2/queue.
List individual jobs:
curl -s "$WA_URL/api/v2/queue/items?state=scheduled&limit=50" -H "Authorization: Bearer $WA_API_KEY"
curl -s "$WA_URL/api/v2/queue/items?state=unknown&limit=50" -H "Authorization: Bearer $WA_API_KEY"Cancel one that has not started sending:
curl -s -X DELETE "$WA_URL/api/v2/messages/wq_9f2c1b7e4a0d4c8e" -H "Authorization: Bearer $WA_API_KEY"The number to watch is unknown24h. Anything above zero means at least one message was dispatched with an unverifiable outcome and needs a human to check the chat.
Housekeeping runs by itself: expired jobs are reaped every 60 seconds, terminal rows are deleted after 7 days, stored media after 72 hours.
12. Honest limitations
| # | Limitation | Why it is accepted |
|---|---|---|
| 1 | Without X-Idempotency-Key, loss-free delivery over the network cannot be guaranteed. A crash before our commit is indistinguishable from "never arrived". | Physics. Use the key. |
| 2 | If the database dies between the WhatsApp call and recording the result, the job becomes unknown and the WhatsApp id exists only in the process log (DISPATCH_ID …). | Automatic resolution is impossible; any assumption is wrong in one direction. |
| 3 | In disabled mode limits are not enforced. | The queue is switched off — there is nothing to enforce. memory-degraded does enforce them. |
| 4 | While the queue runs in memory-degraded, the "nothing is lost" promise does not hold: an unsent backlog lives only in memory and a process crash loses it. Accepted items are additionally written to a forensic log file, but they are never replayed automatically. | Automatic replay would recreate the duplicate-message incident. The v2 send routes therefore refuse with 503 in this mode rather than promise something untrue. |
| 5 | With limits active, POST /api/send-message can answer 504 QUEUE_WAIT_TIMEOUT. | The message is in the queue and goes out. Any other answer would be a lie. |
| 6 | POST /api/v2/queue/replan answers {"replanned": false} and does nothing. | Known defect. Changing the limits re-plans automatically, so the endpoint is redundant rather than harmful. |