Messages
POST /messages writes to a lead from the campaign’s own WhatsApp number, as if a teammate had taken over in the dashboard. The anti-ban rules are enforced server-side and cannot be bypassed.
Endpoints
Section titled “Endpoints”| Method | Path | Scope |
|---|---|---|
| POST | /messages |
messages:send |
Send a message
Section titled “Send a message”curl -X POST "https://app.whatsetter.com/api/v1/messages" \ -H "Authorization: Bearer ws_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8c3d2f9e-7b1a-4e6f-9c0d-2a1b3c4d5e6f" \ -d '{"lead_id":"66f1a2b3c4d5e6f7a8b9c0d1","text":"Are we still on for tomorrow at 2pm?"}'const res = await fetch('https://app.whatsetter.com/api/v1/messages', { method: 'POST', headers: { Authorization: 'Bearer ws_live_…', 'Content-Type': 'application/json', 'Idempotency-Key': '8c3d2f9e-7b1a-4e6f-9c0d-2a1b3c4d5e6f', }, body: JSON.stringify({ lead_id: '66f1a2b3c4d5e6f7a8b9c0d1', text: 'Are we still on for tomorrow at 2pm?', }),});const { data: message } = await res.json();import requests
r = requests.post( "https://app.whatsetter.com/api/v1/messages", headers={ "Authorization": "Bearer ws_live_…", "Idempotency-Key": "8c3d2f9e-7b1a-4e6f-9c0d-2a1b3c4d5e6f", }, json={ "lead_id": "66f1a2b3c4d5e6f7a8b9c0d1", "text": "Are we still on for tomorrow at 2pm?", },)message = r.json()["data"]{ "data": { "id": "66f1a2b3c4d5e6f7a8b9c0e5", "message_id": "true_33612345678@c.us_3EB0C8F2A1D4B5E6F7A8", "lead_id": "66f1a2b3c4d5e6f7a8b9c0d1", "text": "Are we still on for tomorrow at 2pm?", "sent_at": "2026-09-19T08:12:04.000Z", "humanized": true, "typing_ms": 3210, "quota": { "limit": 250, "used": 41, "remaining": 209, "resets_at": "2026-09-20T00:00:00.000Z" } }}| Field | Meaning |
|---|---|
id |
The conversation row id in WhatSetter, or null if the row could not be written after the send. |
message_id |
The WhatsApp message id. |
lead_id |
The lead you wrote to. |
text |
The text sent. |
sent_at |
When the engine accepted it. |
humanized |
Whether the human choreography was played. |
typing_ms |
The typing delay that was simulated, 0 when humanize is false. |
quota |
The number’s daily budget after this send. |
201 means the message was accepted by the WhatsApp engine. Final delivery is asynchronous, like any WhatsApp API. The message appears in the dashboard conversation as a manual reply.
The rules
Section titled “The rules”No cold outreach
Section titled “No cold outreach”The lead must already have a conversation: at least one message exchanged. Otherwise you get 409 lead_not_contacted. First contact goes through a list and campaign or an opt-in push.
Daily quota per number
Section titled “Daily quota per number”Each send consumes the same daily budget as the campaign engine for that WhatsApp number (250 messages a day when no specific quota is set). When it is reached: 429 quota_exceeded, reset at midnight UTC.
Every 201 and 429 carries the budget in headers:
X-Quota-Limit: 250X-Quota-Remaining: 209X-Quota-Reset: 2026-09-20T00:00:00.000ZThe number must be connected
Section titled “The number must be connected”If the campaign has no WhatsApp number, or the number is disconnected, you get 503 agent_disconnected. Check whatsapp_connected on GET /campaigns and reconnect it from the dashboard.
Humanized sending
Section titled “Humanized sending”By default (humanize: true) the send replays a human choreography: read receipt, typing indicator, a typing delay paced to the message length, then the send. Expect the request to take 3 to 15 seconds. This is the recommended mode.
"humanize": false sends instantly, still quota-gated. Use it only for time-critical confirmations.
text is required, 1 to 4096 characters, text only.
Retry safely with Idempotency-Key
Section titled “Retry safely with Idempotency-Key”A message API must be safe to retry: a timeout must never turn into a second real WhatsApp message. Pass an Idempotency-Key header on every send, unique per intent (a UUID, an order id…), up to 200 characters. WhatSetter remembers it for 15 minutes.
| Situation | What a retry with the same key gets |
|---|---|
The first call answered 201 |
The same 201 body, with the header X-Idempotent-Replay: true. No second message. |
| The first call is still running | 409 request_in_flight. Wait and retry. |
The first call failed cleanly (422, 409, 429, 503) |
The key is released: your retry is a real new attempt. |
The first call timed out (502 send_failed) |
409 request_in_flight for the rest of the window: the message may have gone out. Check the conversation transcript before sending again. |
Errors you will meet
Section titled “Errors you will meet”| HTTP | code |
When |
|---|---|---|
| 401 | missing_api_key, invalid_api_key |
Key absent, unknown or revoked. |
| 403 | insufficient_scope |
The key lacks messages:send. |
| 404 | not_found |
Unknown lead, or a lead of another workspace. |
| 409 | lead_not_contacted |
The lead has never been contacted. Go through a list or an opt-in push. |
| 409 | request_in_flight |
Same Idempotency-Key still processing, or an earlier attempt with it timed out. |
| 422 | validation_error |
Missing lead_id, empty or too long text, humanize not a boolean, key over 200 characters. |
| 429 | quota_exceeded |
Daily budget of the number reached. See X-Quota-Reset. |
| 429 | rate_limited |
Too many requests this minute. Honor Retry-After. |
| 500 | internal_error |
Messaging backend not configured on our side. |
| 502 | send_failed |
The engine refused or did not confirm the send. Retry with the same key. |
| 503 | agent_disconnected |
No number on the campaign, or the number is offline. |

