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 spechttps://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"
    }
  ]
}
FieldMeaning
account_idThe id to pass to per-account calls
phoneThe account’s number, E.164 without the +
push_nameThe display name WhatsApp shows for it
statepairing, connected, disconnected, locked or unlinked. connected means the number is working right now. A number that is not working reads disconnected
reasonWhy the account is locked, when it is
statusWhether the number is actually working: live, connecting, starting, not_running, whatsapp_down, suspended, pairing, unlinked or locked (see Account status)
status_reasonThe detail when it is not live, e.g. stopped, no_egress, no_plan, paused or billing
status_sinceWhen it stopped working (RFC 3339), when known
what_to_doWhat is wrong and what to do about it, in a sentence to show your user. Absent while live
countryThe country the number belongs to — not where it connects from
egressHow the account actually reaches WhatsApp right now: direct, socks5 or wireguard
egress_configuredThe egress it is configured to use. If it differs from egress, a tunnel is set but not carrying traffic
egress_ipThe 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.

statusMeaningCalls that need WhatsApp
liveWorkingGo through
connectingRunning, and reconnecting to WhatsApp after a short dropGo through
startingJust paired, or restartingRefused; retry after 30 s
not_runningNot 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 consoleRefused; retry after 60 s when stopped, no retry for no_egress
whatsapp_downRunning but not connected to WhatsApp. Check WhatsApp on the phone and its Linked devicesRefused; retry after 60 s
suspendedNot supposed to run: taken offline (paused), billing stopped (billing) or no plan (no_plan)Refused; the owner has to act
pairingStill being pairedRefused; retry after 30 s
unlinkedWhatsApp removed the linked device. Re-link it in the consoleRefused
lockedWhatsApp locked the numberRefused

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

EndpointDoes
GET /v1/planEvery 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}/planOne 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 parameterDefaultMeaning
after_cursor0Return only messages after this cursor. Omit or 0 to start from the beginning
limit50Page 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
}
FieldMeaning
cursorThis message’s position. Pass the highest one you have seen back as after_cursor
account_idWhich of your accounts it belongs to
directioninbound (received) or outbound (sent by you)
peerThe other party’s number. In a group or channel this is the individual sender, not the conversation
chatThe conversation JID — …@g.us for a group, …@newsletter for a channel
chat_kinddm, group, channel or broadcast
kind / textWhat 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_idRFC 3339 timestamp, and WhatsApp’s id for the message
send_errorSet on an outbound message that failed to send
next_cursorThe highest cursor in this page. On an empty page it is your after_cursor, unchanged, so you never lose your place
has_moretrue when the page was full and more may be waiting — fetch again straight away
account_noticesPresent 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.

EndpointDoes
PUT /v1/webhookSet 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/webhookThe 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/webhookStop 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.

EndpointDoes
POST /v1/accounts/{account_id}/messagesSend 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}/mediaDownload 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}/reactionReact with an emoji; an empty reaction removes yours
PUT /v1/accounts/{account_id}/chats/{chat}/presenceAnnounce typing (composing/paused) to a chat
PUT /v1/accounts/{account_id}/presenceSet the account available/unavailable (online/offline)
POST /v1/accounts/{account_id}/chats/{chat}/historyAsk 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

EndpointDoes
GET /v1/accounts/{account_id}/contactsList 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}/blockedList 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:

    EndpointDoes
    GET /v1/accounts/{account_id}/templatesEvery template with its status (APPROVED, PENDING, REJECTED + rejected_reason), language, category and variables count, plus call_permission: true on one that asks to allow calls
    POST /v1/accounts/{account_id}/templatesSubmit a template (name, category MARKETING/UTILITY, body, body_examples, optional header, footer, buttons, or call_permission instead of buttons); answers 201 with "status":"PENDING"
    DELETE /v1/accounts/{account_id}/templates/{name}Delete it — one language with ?language=pt_BR, or every language of the name
    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":"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 with template, template_language and template_variables (one per variables, in order). A draft that breaks Meta’s rules is refused up front with a 400.

  • 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 on MARKETING and UTILITY templates only, never beside buttons, so a draft with both is refused with a 400:

    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 in GET /v1/messages as an inbound message of kind call_permission, whose text is 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 stored outbound with 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.

StatusreasonWhat to do
400invalid_requestFix the request — retrying it unchanged will not help
401—Missing, unknown or revoked key, or a suspended workspace. Plain-text body
403plan_requiredYour plan does not include this. Change the plan, not the request
404—No such endpoint — see the reference
409account_not_connected, account_lockedThe account cannot act right now (locked never recovers)
422—A parameter failed validation, e.g. limit=500; the body lists which
429quota_exceeded, throttledWait 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

AreaREST
AccountsGET /v1/accounts✅
MessagesGET /v1/messages✅
SendingPOST …/messages, edit, delete, react✅
Chatsread, presence, load history✅
Contactslist, profile, blocklist✅
Templates (Business numbers)list, create, delete✅
Webhookset, read, pause/resume, delete✅
Planworkspace and per number✅
Groups & channels, calls, pairing, AI voicevia MCPMCP 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.