outrnk. | Public API reference

WhatsApp API

WhatsApp Multi-Session API — Public Reference

Base URLhttps://wa.outrnk.io
AuthAuthorization: Bearer <YOUR_API_KEY> — on every request
Content typeapplication/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:

DocumentContent
QUEUE.mdRate limits, the queue, queued vs sent, human-like delays
INTERNAL.mdArchitecture, operations, admin API (German, operators only)

Table of contents

  1. Quickstart — first message in five minutes
  2. How to read a response
  3. Two API generations: v1 and v2
  4. Sending
  5. Media
  6. Interaction
  7. Reading
  8. Groups
  9. Queue and limits
  10. Status and health
  11. Webhooks
  12. Error codes
  13. Limits, sizes and timeouts
  14. 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 this

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

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. Without X-Idempotency-Key there 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 with idempotentReplay: 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:

RuleDetail
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/…)
BehaviourSynchronous — the call blocks until WhatsApp accepted the messageAsynchronous — the message is persisted and scheduled, you get a jobId
Response{status:'sent', messageId}{status:'queued', queueState, jobId, scheduledAt, etaSeconds}
HTTP on success200202 (or 200 on idempotent replay)
Idempotency keynot supportedsupported and strongly recommended
Rate limits enforcedyes (via the same queue)yes
Message typestext, mediatext, media, voice, document, sticker, video, location, contact, poll, forward
Statusfrozen, will not changethe 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)

FieldTypeDefaultMeaning
phonestringRecipient in international format without +, e.g. 4915212345678. German numbers starting with 0 are corrected to 49… automatically.
chatIdstringAlternative to phone. Full WhatsApp id: …@c.us, …@lid or …@g.us (group).
idempotencyKeystringnull8–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.
priorityinteger519. 9 is dispatched first.
notBeforeISO-8601nullDo not send before this instant.
ttlSecondsintegerinstance default (86400)602592000. If no slot exists before expiry the request is rejected with 422 TTL_UNREACHABLE instead of silently expiring later.
optionsobjectnullPer-message WhatsApp options, see below.

Exactly one of phone or chatId is required.

options whitelist (anything else is silently dropped):

OptionApplies toMeaning
quotedMessageIdallReply to this message id
mentionsallArray of …@c.us ids to mention
groupMentionsgroupsArray of group mention objects
linkPreviewtextfalse disables the link preview
isViewOncemediaView-once media
sendMediaAsHdmediaSend image in HD
parseVCardscontactParse 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.

statusMeaning
queuedAccepted 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 elseYou 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).

OutcomeHTTPBody
Delivered200{"success":true,"data":{"messageId":"…","phone":"…","status":"sent"}}
Not delivered500{"success":false,"error":"…"}
Not connected / restarting503{"success":false,"error":"WhatsApp not connected"}
Missing field400{"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 }
      }'
FieldRequiredNotes
messageyesnon-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 }
      }'
FieldRequiredNotes
mediaUrl / mediaBase64 / mediaIdexactly onesee Media
mimetyperequired with mediaBase64e.g. image/jpeg
filenameoptionalshown to the recipient
captionoptionalmessage 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"
      }'
RuleResult
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 omittedaccepted, 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.

WayFieldBest forHard limit
URLmediaUrlthe default16 MiB, public HTTP/HTTPS only
Inline base64mediaBase64 + mimetypesmall files7 MiB binary
Upload first, reference latermediaIdlarge files, repeated use64 MiB, kept 72 h
Infrastructure caveat. The reverse proxy in front of this API currently has no client_max_body_size override, so nginx's default of 1 MB applies to request bodies. In practice that caps mediaBase64 at roughly 750 KB of binary and blocks large uploads with an nginx-generated 413 that never reaches our code. Until your operator sets client_max_body_size 32m;, prefer mediaUrl for 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.

ErrorMeaning
400 VALIDATION_FAILEDmultipart field file missing or empty
413 PAYLOAD_TOO_LARGEabove 64 MiB (data.maxBytes tells you the ceiling)
507 MEDIA_STORE_UNAVAILABLEreason: "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.pdf

With ?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.jpg

Without ?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.

MethodPathBodyReturns
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

MethodPathQueryReturns
GET/api/v2/chatslimit (1–200, default 50), cursor (offset), archived (1/true){items:[chat], total, nextCursor}
GET/api/v2/chats/:chatIdchat
GET/api/v2/chats/:chatId/messageslimit (1–200, default 50), fromMe{items:[message], limit, capped}
GET/api/v2/messages/:msgIdmessage
GET/api/v2/contacts/:contactIdcontact
GET/api/v2/contacts/:contactId/picture{contactId, pictureUrl}
GET/api/v2/contacts/:phone/exists{phone, registered, whatsappId}
GET/api/check-number/:phonev1 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. limit above 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: true in 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.

MethodPathBody
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:

MethodPathPurpose
GET/api/v2/queuecounts, next dispatch time, current limits
GET/api/v2/queue/items?state=scheduled&limit=50&cursor=…list jobs
POST/api/v2/queue/replanforce a re-plan
GET/api/v2/messages/:jobIdstatus of one job
GET/api/v2/messages?idempotencyKey=order-4711resolve your own key to a job
DELETE/api/v2/messages/:jobIdcancel a job that has not started sending
POST/api/v2/messages/:jobId/requeuecreate 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
  }
}
stateTerminalMeaning
queuednoPersisted, no slot assigned yet
schedulednoSlot assigned, waiting for scheduledAt
sendingnoBeing dispatched right now
sentyesDelivered. Check deliveryConfidence.
failedyesProvably not sent (bad number, media download failed, validation)
unknownyesDispatched, outcome unverifiable. Never retried automatically. Needs a human.
cancelledyesCancelled before dispatch
expiredyesTTL elapsed before a slot came free
deliveryConfidenceMeaning
confirmedWhatsApp returned a real message id
assumedThe message went out but no id came back (known library behaviour). Treat as delivered.
unknownOutcome 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

HeaderValue
X-Instance-IDinstance uuid
X-Event-Typeevent name
X-Event-Idev_… — use this to deduplicate
X-Event-Version2
X-Webhook-Signaturesha256=<hex> — HMAC-SHA256 of the exact request body
X-Webhook-Timestampunix ms, same value as timestamp
X-Webhook-Signature-V2t=<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

EventPayload
messagefull message object incl. media (see below)
message_sent{id, to, body, timestamp} (own outgoing messages)
message_ackdelivery/read receipts
message_reactionreaction 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_statelifecycle

Queue events

EventPayload
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

HTTPcodeMeaningWhat to do
400VALIDATION_FAILEDA field is missing or malformed. data.field names it.Fix the request.
400IDEMPOTENCY_KEY_CONFLICTHeader and body carry different idempotency keys.Send only one.
400VOICE_MIMETYPE_UNSUPPORTEDmimetype cannot be a voice note.Use OGG/Opus.
400STICKER_VIDEO_UNSUPPORTEDVideo stickers need ffmpeg — not available.Send an image.
400NOT_A_GROUPThe chat id is not a group.Check the id.
401UNAUTHORIZEDAuthorization: Bearer … header missing or malformed.Add it.
401INVALID_API_KEYKey unknown.Check the key.
404JOB_NOT_FOUND / MESSAGE_NOT_FOUND / CHAT_NOT_FOUND / MEDIA_NOT_FOUND / NO_MEDIATarget does not exist (any more).
409IDEMPOTENCY_PAYLOAD_MISMATCHSame key, different payload. data.jobId is the original.Use a new key, or resend the identical payload.
410MEDIA_GONEMetadata exists, file was removed from disk.Re-upload.
413PAYLOAD_TOO_LARGEBody/file above the limit. data.maxBytes is the ceiling.Use mediaUrl or the upload endpoint.
422QUEUE_FULLMore than 5000 pending jobs for this instance.Back off, drain the queue.
422TTL_UNREACHABLEWith the current limits no slot exists before expiresAt.Raise ttlSeconds or the limits.
429RATE_LIMITEDMore than 60 interaction calls in 60 s.Back off.
500WA_CALL_FAILEDThe WhatsApp call itself failed.Retry later.
502INSTANCE_UNREACHABLEGateway could not reach the instance process.Message was not accepted. Safe to retry.
503INSTANCE_OFFLINEWhatsApp is not connected. data.instance has the details.Wait / re-link.
503QUEUE_STORE_UNAVAILABLEPersistent 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.
503QUEUE_PAUSEDInstance 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).
504QUEUE_WAIT_TIMEOUTv1 route waited 20 s, message still queued. Not lost. data.jobId follows it.Poll GET /api/v2/messages/<jobId>.
504INSTANCE_TIMEOUTGateway reached the instance but got no answer in time.The message may have been accepted. Only retry with an idempotency key.
507MEDIA_STORE_UNAVAILABLEDisk full or file above the store ceiling.Use mediaUrl.

13. Limits, sizes and timeouts

ItemValueConfigurable by operator
Media via URL16 MiBMEDIA_MAX_BYTES
Media inline base647 MiB binary (nginx caps it at ~750 KB today)MEDIA_BASE64_MAX_BYTES
Media upload64 MiBMEDIA_UPLOAD_MAX_BYTES
Uploaded/stored media retention72 hMEDIA_RETENTION_HOURS
Per-instance media store2048 MB, oldest evicted firstMEDIA_STORE_MAX_MB
Bulk entries per callno fixed cap — bounded by the 10 MB JSON body and by QUEUE_MAX_PENDING
fetchMessages limit200, 30 s deadlineFETCH_MESSAGES_MAX
Interaction rate limit60 per 60 s per instanceINTERACTION_RATE_POINTS
Pending jobs per instance5000QUEUE_MAX_PENDING
v1 synchronous wait20 s, hard-capped at LEGACY_CALLER_TIMEOUT_MS − 5 s (25 s today)LEGACY_SYNC_TIMEOUT_MS
Idempotency key retention7 days
Idempotency key format8–128 chars, [A-Za-z0-9_.:-]
Gateway timeout, send routes60 s
Gateway timeout, read/interaction30 s
Gateway timeout, queue status10 s
Gateway timeout, upload120 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

FeatureReason
Buttons, list messagesDisabled by WhatsApp server-side. The library logs a deprecation warning and they do not render.
Video/GIF → stickerRequires 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 PTTSame reason. Send OGG/Opus.
Channels / newslettersUntested library surface, separate id namespace.
Status broadcastThe library rejects most content types for status and returns null.
History syncPulls 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

AffectedSymptom
POST /api/v2/queue/replanAccepted, 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

  1. The one-paragraph version
  2. The three knobs
  3. What "queued" means in the response
  4. When exactly does my message go out?
  5. Worked examples
  6. What the legacy routes do when a limit is active
  7. Changing limits while the queue is full
  8. Restarts, crashes and "nothing gets lost"
  9. Job states, end to end
  10. How to configure the limits
  11. Monitoring the queue
  12. 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

SettingMeaningDefault (existing instances)Default (new instances)
maxPerHourUpper bound over a sliding 60-minute window. null = unlimited.null40
maxPer24hUpper bound over a sliding 24-hour window. null = unlimited.null500
minDelayMs / maxDelayMsRandom pause between two messages. Always applies, even when the queue is empty.1500 / 30004000 / 12000
quietHoursNo sending during this local window, e.g. {"start":"22:00","end":"07:00","tz":"Europe/Berlin"}. null = always allowed.null22:00–07:00 Europe/Berlin
defaultTtlSecondsHow long an unsent message stays valid.86400 (24 h)86400
legacyQueueModesync = the old routes keep blocking until sent; async = they answer queued immediately.syncsync
enabledWhether the persistent queue is used at all.falsetrue
storeIncomingMediaKeep incoming attachments on disk for 72 h.falsefalse

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.

FieldIt meansWhat 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.

MessageScheduledWhy
1–4009:00:00 → ~09:05:20random 4–12 s apart, average ~8 s ⇒ 40 messages in ≈5.3 minutes
4110:00:00.001Hourly 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:

SituationHTTPBody
Dispatched within 20 s200{"success":true,"data":{"messageId":"…","phone":"…","status":"sent"}} — unchanged
Not dispatched, failed500{"success":false,"error":"…"} — unchanged
Still waiting after 20 s504{"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:

  1. Everything in state scheduled goes back to queued.
  2. A message currently in state sending is never interrupted — it runs to completion.
  3. The planner assigns fresh times, in the same order as before (priority, then arrival).
  4. A queue.replanned webhook 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 happensStored stateConsequence
Before we committed your requestno row at allNothing was sent. Your client sees a network error. Retry with the same idempotency key ⇒ exactly one message.
After acceptance, before dispatchqueued / scheduledNothing lost. On restart every scheduled row is re-planned (the old plan is stale after downtime) and goes out.
During the WhatsApp callsendingOn 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 itsendingSame 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 │
                                                                    └────────┘
StateTerminalMeaning
queuednoPersisted, waiting for a slot
schedulednoSlot assigned, waiting for the clock
sendingnoBeing dispatched right now — cannot be cancelled
sentyesDelivered. deliveryConfidence is confirmed (real WhatsApp id) or assumed (delivered, id not returned)
failedyesProvably not sent: number not on WhatsApp, media download failed, SSRF rejection, invalid payload
unknownyesDispatched, outcome unverifiable. Needs a human.
cancelledyesCancelled via DELETE /api/v2/messages/:jobId before dispatch
expiredyesTTL 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

FieldRuleError text
enabledbooleanqueue.enabled must be a boolean
maxPerHournull or integer 1…1000queue.maxPerHour must be null or an integer 1..1000
maxPer24hnull or integer 1…20000queue.maxPer24h must be null or an integer 1..20000
maxPer24h vs maxPerHourmust be ≥ when both are setqueue.maxPer24h must be >= queue.maxPerHour
minDelayMsinteger 0…600000queue.minDelayMs must be an integer 0..600000
maxDelayMsinteger 0…3600000 and ≥ minDelayMsqueue.maxDelayMs must be >= queue.minDelayMs
quietHoursnull or {start,end,tz} with HH:MM and a valid IANA zonequeue.quietHours.tz is not a valid IANA timezone
quietHours.start vs endmust differqueue.quietHours.start and end must differ
legacyQueueModesync or asyncqueue.legacyQueueMode must be 'sync' or 'async'
defaultTtlSecondsinteger 60…2592000queue.defaultTtlSeconds must be an integer 60..2592000
storeIncomingMediabooleanqueue.storeIncomingMedia must be a boolean

Unknown keys are silently ignored (forward compatibility), so sending a newer config to an older build does not fail.

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.

modePersistent?Limits enforced?v1 routesv2 send routes
pgyesyesnormalnormal
memory-degradednoyes — the same gate runs in the fallbacknormal, nothing regresses503 QUEUE_STORE_UNAVAILABLE
disablednono — the queue is switched off for this instanceexactly like before the queue existed503 QUEUE_STORE_UNAVAILABLE
unavailablenonolike disabled503

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

#LimitationWhy it is accepted
1Without 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.
2If 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.
3In disabled mode limits are not enforced.The queue is switched off — there is nothing to enforce. memory-degraded does enforce them.
4While 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.
5With 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.
6POST /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.