REST API documentation
Quick start
1. Create a workspace → https://console.whatsmcp.com/register
2. Link a WhatsApp number → Console → Pair device (scan the QR with your phone)
…or, for a Business (official) number: Add a number → Connect with Facebook
3. Create an API key → https://console.whatsmcp.com/keys
Then:
curl -H "X-API-Key: $WAMCP_KEY" https://api.whatsmcp.com/v1/accounts
The REST API is the same workspace as the MCP server: the same accounts, the same plan, the same API keys. Use MCP for AI agents; use REST for scripts, backends, CRMs and anything else that speaks HTTP.
Base URL
https://api.whatsmcp.com
Every endpoint is under /v1. Requests and responses are JSON over HTTPS.
The contract is published as an OpenAPI 3.1 document, generated from the code that serves it, so it cannot drift:
| Interactive reference (Swagger UI) | https://api.whatsmcp.com/docs |
| OpenAPI spec | https://api.whatsmcp.com/openapi.json · .yaml |
In the interactive reference, Authorize with your key and every endpoint gets a Try it out button that calls your real workspace.
Authentication
Every /v1 request carries an API key, in either header:
curl -H "Authorization: Bearer $WAMCP_KEY" https://api.whatsmcp.com/v1/accounts
curl -H "X-API-Key: $WAMCP_KEY" https://api.whatsmcp.com/v1/accounts
Create keys at Console → API keys. They look like
wamcp_live_XXXXXXXXXXXX_… and are shown exactly once — we store only a hash, so a lost key
is replaced, never recovered. Mint one key per service or environment and revoke individually.
The key is the workspace. There is no tenant parameter anywhere, and no request can reach another workspace’s accounts or messages.
A missing, unknown or revoked key — or a suspended workspace — is answered 401.
Accounts
GET /v1/accounts
The WhatsApp accounts in your workspace. Every per-account call needs an account_id from
here, and a number can act only while its status is live (its state then reads
connected).
curl -H "X-API-Key: $WAMCP_KEY" https://api.whatsmcp.com/v1/accounts
{
"accounts": [
{
"account_id": "01J8Z3Q4R5S6T7V8W9X0Y1Z2A3",
"phone": "447700900123",
"push_name": "Support",
"state": "connected",
"status": "live",
"country": "GB",
"egress": "wireguard",
"egress_configured": "wireguard",
"egress_ip": "203.0.113.24"
}
]
}
| Field | Meaning |
|---|---|
account_id | The id to pass to per-account calls |
phone | The account’s number, E.164 without the + |
push_name | The display name WhatsApp shows for it |
state | pairing, connected, disconnected, locked or unlinked. connected means the number is working right now. A number that is not working reads disconnected |
reason | Why the account is locked, when it is |
status | Whether the number is actually working: live, connecting, starting, not_running, whatsapp_down, suspended, pairing, unlinked or locked (see Account status) |
status_reason | The detail when it is not live, e.g. stopped, no_egress, no_plan, paused or billing |
status_since | When it stopped working (RFC 3339), when known |
what_to_do | What is wrong and what to do about it, in a sentence to show your user. Absent while live |
country | The country the number belongs to — not where it connects from |
egress | How the account actually reaches WhatsApp right now: direct, socks5 or wireguard |
egress_configured | The egress it is configured to use. If it differs from egress, a tunnel is set but not carrying traffic |
egress_ip | The public IP its traffic leaves from |
Fields that do not apply are omitted rather than sent empty.
Account status
status says whether a number is working right now, not just what its connection last
reported. A call that needs the number’s WhatsApp connection (sending, editing, deleting
or reacting to messages, presence, loading history, contacts, profiles, the blocklist)
is refused at once on a number that is not working, with 409 account_not_connected (see
Errors). Reads of stored messages keep working whatever the status.
status | Meaning | Calls that need WhatsApp |
|---|---|---|
live | Working | Go through |
connecting | Running, and reconnecting to WhatsApp after a short drop | Go through |
starting | Just paired, or restarting | Refused; retry after 30 s |
not_running | Not running. status_reason stopped is a problem on our side; no_egress means the number has no egress, and its owner has to set one in the console | Refused; retry after 60 s when stopped, no retry for no_egress |
whatsapp_down | Running but not connected to WhatsApp. Check WhatsApp on the phone and its Linked devices | Refused; retry after 60 s |
suspended | Not supposed to run: taken offline (paused), billing stopped (billing) or no plan (no_plan) | Refused; the owner has to act |
pairing | Still being paired | Refused; retry after 30 s |
unlinked | WhatsApp removed the linked device. Re-link it in the console | Refused |
locked | WhatsApp locked the number | Refused |
A Business (official) number has no connection of its own to run, so its status follows
its plan: suspended with billing or no_plan, otherwise live. This check never
refuses its calls; the Cloud API’s own checks answer for it.
Plan, limits and usage
| Endpoint | Does |
|---|---|
GET /v1/plan | Every number’s plan and limits (lines), message usage for the last hour and 24 hours, and the tools no number’s plan includes. plan is "mixed" when your numbers are on different plans |
GET /v1/accounts/{account_id}/plan | One number’s plan and limits, and excluded_tools — why a call on that number answers plan_required |
Messages
GET /v1/messages
Messages across all your accounts, oldest first, with a cursor.
| Query parameter | Default | Meaning |
|---|---|---|
after_cursor | 0 | Return only messages after this cursor. Omit or 0 to start from the beginning |
limit | 50 | Page size, 1–200 |
curl -H "X-API-Key: $WAMCP_KEY" \
"https://api.whatsmcp.com/v1/messages?after_cursor=1041&limit=100"
{
"messages": [
{
"cursor": 1042,
"account_id": "01J8Z3Q4R5S6T7V8W9X0Y1Z2A3",
"direction": "inbound",
"peer": "447700900456",
"chat": "[email protected]",
"chat_kind": "dm",
"text": "Hi, is my order on its way?",
"kind": "text",
"at": "2026-09-25T09:14:03Z",
"message_id": "3EB0C767D26A1D5F8C21"
}
],
"next_cursor": 1042,
"has_more": false
}
| Field | Meaning |
|---|---|
cursor | This message’s position. Pass the highest one you have seen back as after_cursor |
account_id | Which of your accounts it belongs to |
direction | inbound (received) or outbound (sent by you) |
peer | The other party’s number. In a group or channel this is the individual sender, not the conversation |
chat | The conversation JID — …@g.us for a group, …@newsletter for a channel |
chat_kind | dm, group, channel or broadcast |
kind / text | What the message is (text, image, video, audio, document, sticker, reaction, location, contact, poll, template, call_permission, …) and its body. Text, captions, template text and call_permission carry a body — see Business (official) numbers for call_permission |
at, message_id | RFC 3339 timestamp, and WhatsApp’s id for the message |
send_error | Set on an outbound message that failed to send |
next_cursor | The highest cursor in this page. On an empty page it is your after_cursor, unchanged, so you never lose your place |
has_more | true when the page was full and more may be waiting — fetch again straight away |
account_notices | Present when a number this read covers is not working, so new messages are not arriving on it: one entry per such number, with account_id, status, status_reason, since and a message to show your user. The same field comes with GET …/chats/{chat} and GET …/messages/{message_id} |
Reading incoming messages
The API never pushes: you read on your own schedule, and the cursor is what makes that reliable — no gaps and no duplicates, even for two messages in the same millisecond, which a timestamp cannot promise.
after = 0 # or the cursor you stored last time
repeat:
r = GET /v1/messages?after_cursor={after}&limit=200
for m in r.messages:
if m.direction == "inbound":
handle(m)
after = r.next_cursor # persist it — it is your bookmark
if not r.has_more:
sleep(a few seconds) # caught up; poll again later
Store next_cursor durably and a restart resumes exactly where it stopped.
Webhook
If you would rather be called than poll, register a webhook: each inbound message is POSTed to your HTTPS endpoint as it arrives (paid plans). A workspace has one webhook, so the path carries no account.
| Endpoint | Does |
|---|---|
PUT /v1/webhook | Set or replace the endpoint (url, optional events). The answer carries the signing secret once — store it now; every call issues a new one |
GET /v1/webhook | The endpoint, its events, whether it is enabled and, if paused, why and by whom. configured: false when none is set |
PATCH /v1/webhook | {"enabled": false} pauses delivery, true resumes it — the endpoint and secret are kept |
DELETE /v1/webhook | Stop delivery and discard the endpoint and its secret |
curl -X PUT -H "X-API-Key: $WAMCP_KEY" -H "Content-Type: application/json" \
https://api.whatsmcp.com/v1/webhook \
-d '{"url":"https://example.com/whatsapp"}'
To stop deliveries for a while, PATCH rather than delete and re-create: re-creating issues a
new secret. Messages keep arriving either way and stay readable with GET /v1/messages. The
delivery payload, its headers and how to verify the signature are in
Webhooks; the same webhook can be managed over MCP or in
Console → Webhooks.
Sending and messaging
Per-account operations live under /v1/accounts/{account_id}/…, where {account_id} is one
of the ids from GET /v1/accounts. Send a message:
curl -X POST -H "X-API-Key: $WAMCP_KEY" -H "Content-Type: application/json" \
https://api.whatsmcp.com/v1/accounts/$ACCOUNT_ID/messages \
-d '{"to":"447700900123","text":"Your order is on its way."}'
A send answers 200 when WhatsApp confirmed it ("status":"sent"), or 202 when it was
accepted but not yet confirmed ("status":"queued") — queued messages may still arrive, so do
not resend. A send to a number that is not working is refused before anything is sent:
409 account_not_connected, with the number’s account_status (see
Account status). Nothing was queued, so sending again once the number is
live cannot deliver it twice. Attach an image, document or audio clip as base64 (image_base64,
document_base64, audio_base64); the text becomes the caption. For a Business (Cloud API)
number, template sends a pre-approved template.
| Endpoint | Does |
|---|---|
POST /v1/accounts/{account_id}/messages | Send text, an attachment, or a template |
GET /v1/accounts/{account_id}/chats/{chat} | Read one conversation (a number or a group JID), newest history included |
GET /v1/accounts/{account_id}/messages/{message_id} | Fetch one stored message |
GET /v1/accounts/{account_id}/messages/{message_id}/media | Download a received attachment as base64 |
PATCH /v1/accounts/{account_id}/messages/{message_id} | Edit a message you sent (within WhatsApp’s ~20-minute window) |
DELETE /v1/accounts/{account_id}/messages/{message_id} | Delete a message for everyone (?chat= required) |
PUT /v1/accounts/{account_id}/messages/{message_id}/reaction | React with an emoji; an empty reaction removes yours |
PUT /v1/accounts/{account_id}/chats/{chat}/presence | Announce typing (composing/paused) to a chat |
PUT /v1/accounts/{account_id}/presence | Set the account available/unavailable (online/offline) |
POST /v1/accounts/{account_id}/chats/{chat}/history | Ask the phone for older messages, then read them with GET …/chats/{chat} |
A {chat} or {message_id} path segment is URL-encoded as usual — a group JID
[email protected] becomes 1203630%40g.us.
Contacts
| Endpoint | Does |
|---|---|
GET /v1/accounts/{account_id}/contacts | List or search the address book (?query=, ?include_pictures=) |
GET /v1/accounts/{account_id}/profiles/{phone} | Look up a number’s public WhatsApp profile |
GET /v1/accounts/{account_id}/blocked | List blocked contacts |
PUT /v1/accounts/{account_id}/blocked/{jid} | Block a contact |
DELETE /v1/accounts/{account_id}/blocked/{jid} | Unblock a contact |
Every per-account call is gated by the same plan rules as its MCP twin, resolves the account only within your workspace, and answers a refusal the same way (see Errors). The full request and response shape of each is in the interactive reference.
Business (official) numbers
The REST API serves Business (official) numbers — those on Meta’s WhatsApp Cloud API — through
the same endpoints as Personal ones: list accounts, read messages, send. Each account in the
accounts list carries its backend and features. What differs:
-
Templates. List a number’s templates with their review status, submit a new one for Meta’s review, or delete one:
Endpoint Does GET /v1/accounts/{account_id}/templatesEvery template with its status ( APPROVED,PENDING,REJECTED+rejected_reason), language, category andvariablescount, pluscall_permission: trueon one that asks to allow callsPOST /v1/accounts/{account_id}/templatesSubmit a template ( name,categoryMARKETING/UTILITY,body,body_examples, optionalheader,footer,buttons, orcall_permissioninstead ofbuttons); answers201with"status":"PENDING"DELETE /v1/accounts/{account_id}/templates/{name}Delete it — one language with ?language=pt_BR, or every language of the namecurl -X POST -H "X-API-Key: $WAMCP_KEY" -H "Content-Type: application/json" \ https://api.whatsmcp.com/v1/accounts/$ACCOUNT_ID/templates \ -d '{"name":"order_update","category":"UTILITY", "body":"Hi {{1}}, order {{2}} is on its way.","body_examples":["Ada","#1042"]}'Meta reviews a new template, usually within minutes; poll the list until it is
APPROVED, then send it withtemplate,template_languageandtemplate_variables(one pervariables, in order). A draft that breaks Meta’s rules is refused up front with a400. -
The 24-hour window. Outside 24 hours from the customer’s last message, a free-form send to them is refused; only a template can reopen the conversation.
-
Calls. A Business number takes WhatsApp calls on a line of its own. With Calls out on, the line also calls customers, from the Pipedrive call window or a phone on the line, once each customer has allowed your calls. Both are set up in the console, on the number’s SIP trunk page. Over REST you ask for the permission. Submit a template with
"call_permission": true. Meta takes it onMARKETINGandUTILITYtemplates only, never besidebuttons, so a draft with both is refused with a400:curl -X POST -H "X-API-Key: $WAMCP_KEY" -H "Content-Type: application/json" \ https://api.whatsmcp.com/v1/accounts/$ACCOUNT_ID/templates \ -d '{"name":"call_ok","category":"UTILITY","call_permission":true, "body":"Can we call you on WhatsApp about your order? You can change this any time."}'Once it is
APPROVED, send it like any template; the customer sees Allow and Don’t allow under your text. Their answer arrives inGET /v1/messagesas aninboundmessage of kindcall_permission, whosetextis one of Allowed calls (no expiry), Allowed calls until 23 Oct, Declined calls or Calls permission withdrawn automatically. A request sent from the Pipedrive call window is storedoutboundwith its own text. Call customers on WhatsApp from your Business number walks through the whole flow, with WhatsApp’s limits and what calls cost.
Errors
Errors are RFC 9457 problem documents,
Content-Type: application/problem+json:
{
"status": 403,
"title": "Forbidden",
"detail": "wa_list_messages is not included in your plan …",
"reason": "plan_required"
}
reason is a stable code you can branch on; detail is for humans and may change. When
waiting helps, retry_after_seconds is set and the response carries a Retry-After header —
when it is absent, retrying the same call will not succeed.
| Status | reason | What to do |
|---|---|---|
400 | invalid_request | Fix the request — retrying it unchanged will not help |
401 | — | Missing, unknown or revoked key, or a suspended workspace. Plain-text body |
403 | plan_required | Your plan does not include this. Change the plan, not the request |
404 | — | No such endpoint — see the reference |
409 | account_not_connected, account_locked | The account cannot act right now (locked never recovers) |
422 | — | A parameter failed validation, e.g. limit=500; the body lists which |
429 | quota_exceeded, throttled | Wait Retry-After seconds, then retry |
500 | — | Our fault. The detail carries a ref — quote it to support |
A 409 account_not_connected names why in account_status and account_status_reason (the
values of the number’s status and status_reason) and, when known, since:
{
"status": 409,
"title": "Conflict",
"detail": "This number is not running because of a problem on our side, not anything you need to fix; try again later.",
"reason": "account_not_connected",
"retry_after_seconds": 60,
"account_status": "not_running",
"account_status_reason": "stopped",
"since": "2026-10-10T19:38:03Z"
}
Without retry_after_seconds, waiting will not bring the number back: its owner has to act,
and detail says how.
Each endpoint is gated by your plan exactly as its MCP twin is: GET /v1/messages needs what
wa_list_messages needs, and so on.
What’s available
| Area | REST | |
|---|---|---|
| Accounts | GET /v1/accounts | ✅ |
| Messages | GET /v1/messages | ✅ |
| Sending | POST …/messages, edit, delete, react | ✅ |
| Chats | read, presence, load history | ✅ |
| Contacts | list, profile, blocklist | ✅ |
| Templates (Business numbers) | list, create, delete | ✅ |
| Webhook | set, read, pause/resume, delete | ✅ |
| Plan | workspace and per number | ✅ |
| Groups & channels, calls, pairing, AI voice | via MCP | MCP only, for now |
The REST API grows toward full parity with the MCP server — the same workspace and the same key. Everything not yet on REST is available today through MCP, and each new endpoint appears in the OpenAPI spec the moment it ships.