Docs

A REST API at https://api.wuapi.dev, a TypeScript SDK and signed webhooks, for one number or a platform of projects. The full reference is in openapi.json.

For a coding agent: the whole documentation as one Markdown file at /llms-full.txt, and an index at /llms.txt. For an AI client, the MCP server.

Quickstart

Four steps from nothing to a sent message. Each is covered in detail further down.

  1. Create an account at wuapi.dev. You start on the Free plan, with no card: 1 number, 2,000 messages and 0.5 GB of proxy traffic a month, with sending and webhooks included. Upgrade under Billing to connect more numbers.
  2. Every organization starts with a default API key, shown on the Overview. Copy it, or create another under API keys, and store it as WUAPI_API_KEY.
  3. Connect an account: open Accounts, choose Connect account, pick the country and city the number exits from, and scan the QR code with WhatsApp on the phone (Settings, Linked devices, Link a device), or link with a pairing code instead. Copy the account ID when it reports ready.
  4. Send a message with curl, or with the TypeScript SDK after npm install @wuapidev/sdk.
curl https://api.wuapi.dev/v1/messages \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4417-shipped" \
  -d '{
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "to": "+584241112233",
    "type": "text",
    "text": "Your order has shipped."
  }'

The response is 202 with the message in queued status. It moves to sent when WhatsApp accepts it, then delivered and read as receipts arrive. Register a webhook endpoint to be told about each change instead of polling. The Idempotency-Key header makes the send safe to retry: the same key within 24 hours returns the first response instead of sending twice.

Authentication

Every request goes to https://api.wuapi.dev and carries an API key as a bearer token. Keys start with wu_live_.

curl https://api.wuapi.dev/v1/me \
  -H "Authorization: Bearer wu_live_..."
{
  "object": "auth_context",
  "organization": {
    "object": "organization",
    "id": "w82t6y1u5i9o3p7a2s6d0f4g8h2j6k1l",
    "name": "Acme"
  },
  "apiKey": {
    "object": "api_key",
    "id": "j48c2d5e8f1g4h7i0k3l6m9n2p5q8r1s",
    "name": "Production",
    "projectId": null,
    "keyPrefix": "wu_live_ab12",
    "last4": "9f3c",
    "createdAt": "2026-09-20T09:00:00.000Z",
    "lastUsedAt": "2026-09-24T14:00:00.000Z",
    "revokedAt": null
  },
  "project": null
}

GET /v1/me returns the organization, the key the request was made with (apiKey.projectId is set for a project key) and project, the project the request is scoped to, or null. Requests are checked against a SHA-256 hash of the key. The API returns a full key only when it creates one; the dashboard can show it again to the organization's owner, from an AES-256-GCM encrypted copy. Revoke a key and it stops working on the next request.

Keep keys on your server. An organization key can send from every connected account in the organization. A project key reaches only its project: see Projects.

Conventions

The whole API follows the same rules. Learn them once.

Resources and lists

Every response body is a resource or a list. A resource is returned as is, never wrapped, and its object field names its type: account, message, group, channel, project, invitation, webhook_endpoint, and so on. Every list has the same shape:

{
  "object": "list",
  "items": [
    {
      "object": "account",
      "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
      "...": "..."
    }
  ],
  "nextCursor": "eyJvIjo1MH0"
}

Lists take ?limit (1 to 100, default 50) and ?cursor. Pass nextCursor back as cursor until it is null. Lists read live from WhatsApp (groups, channels, blocklist, bots) page the same way. Creating returns 201 with the resource, updating 200, deleting 204. Sends are queued and return 202.

Names and values

  1. Fields are camelCase. Enum values are lowercase snake_case: qr_ready, pairing_code.
  2. A resource's own id is id; a reference to another is <resource>Id: accountId, projectId, contactId, replyToMessageId.
  3. Timestamps are ISO 8601 strings in UTC and end in At: createdAt, sentAt, expiresAt.
  4. Durations and sizes carry the unit: durationSeconds, queueTimeoutMinutes, proxyBytes. Money is integer cents in …Cents, next to currency.
  5. Counts end in Count, URLs in Url, and a name WhatsApp reported is profileName.

WhatsApp identities

whatformatexample
a contact with a known numberE.164 with ++584241112233
a contact whose number WhatsApp hideslid: and digitslid:201843727138927
a groupWhatsApp group id120363041234567890@g.us
a channelWhatsApp channel id120363198765432109@newsletter
the account's storiesliteralstories

A message's chatId is the contact for a direct chat, the group id, the channel id, or stories, and chatType says which: direct, group, channel or story. Wherever you pass a chat or a contact (to, {chatId}, {contactId}, contactIds), any format in the table works, and a number may also be plain digits.

WhatsApp usernames

A WhatsApp user can pick a username (@lina.morales) and hide their number. A chat they start from their username reaches you as lid:<digits>, never as a number, and their messages carry username (lowercase, without the @) next to profileName when WhatsApp shared it. contacts/lookup and contacts/check return it as username too, and contact.updated fires when it becomes known or changes. Reply with the lid: id, or with to: "@lina.morales", which works for a contact this account already has a chat with. WhatsApp does not let a linked device look up any other username (nor take the 4-digit username key a first message by username needs), so an unknown one answers 400 username_not_supported, and fields that take contact ids refuse usernames the same way.

Errors

Every error is {code, message, details?}, with the same code for the same situation everywhere. Branch on code. The full list is in Errors.

Headers

headerdirectionwhat
AuthorizationrequestBearer wu_live_... on every request.
Wuapi-ProjectrequestScope an organization key to one project: its id or ext:<externalId>. See Projects.
Idempotency-KeyrequestOn any POST, up to 255 characters. The first successful (2xx) response is kept for 24 hours and returned again for the same key in the same scope. The same key with a different request, or while the first one is still running, answers 409 idempotency_conflict. Failed responses are not kept, so retry them with the same key.
x-request-idresponseOn every response. Search for it in the dashboard (Logs, Requests) to see the request and what it created, or quote it when you contact support.
RateLimit-Limit, RateLimit-Remaining, RateLimit-ResetresponseYour key's budget for the current minute: the limit, what is left, and the seconds until it resets.
Retry-AfterresponseOn every 429, in seconds.
Idempotent-Replayedresponsetrue when the response is a replay of an earlier request with the same Idempotency-Key.
Original-RequestresponseOn a replay: the x-request-id of the request that produced the stored response.

Coding agents

Two ways to give a coding agent what it needs to write wuapi code. The Markdown is a one-off paste into any agent. The skills are installed once, and the agent loads the one a task needs.

The docs as one Markdown file

https://wuapi.dev/llms-full.txt holds every section of these docs, every endpoint of openapi.json with its error codes, and every webhook event with its payload. It is rebuilt on every deploy. /llms.txt is a short index of it. Copy it from the home page or the dashboard Overview, which put a short preamble in front telling the agent what it is reading.

Skills

npx skills add wuapidev/wuapi

The skills live in the public repository wuapidev/wuapi, one SKILL.md per task, for agents that read skills, such as Claude Code. Each one is a workflow plus a reference, with TypeScript examples that use the real SDK and curl where it helps. wuapi-rules is meant to be loaded always; the others load when relevant.

skillcovers
wuapi-rulesAlways loaded. What wuapi is and is not, auth, projects, errors, pagination, idempotency, pacing, webhook signing.
link-accountLink a number by QR or pairing code, wait for ready, pick the exit country, reconnect or log out.
send-messageEvery send type and option, replies, mentions, polls, events, edits, reactions, Status and channel posts.
receive-webhooksEndpoints, signature verification, the full event catalog, retries and history sync.
groups-and-channelsGroups, communities, join requests, invite links and channels.
chats-contacts-profileChat actions, read receipts, labels, contacts, blocklist, profile, privacy and calls.
projects-and-invitationsA platform on wuapi: projects, project keys, per-project webhooks and usage, invitations, branding.

Every skill is checked against the code before it ships: the webhook events it names exist, the paths it names are in openapi.json, and its TypeScript examples compile against the SDK.

To let an AI client act on your account instead of writing code for it, connect the MCP server: wuapi.dev/docs/mcp.

MCP server

The wuapi MCP server gives an AI client that speaks the Model Context Protocol, such as Claude, Cursor or VS Code, 51 tools over your wuapi account: send a message, show the QR code that links a number, read a conversation, add people to a group, set up a webhook, invite a customer. Each tool call is one request to the REST API with your API key, so the key's scope, the limit of 600 requests per minute per key and the dashboard Logs apply as for any other client.

local (stdio)hosted (Streamable HTTP)
runson your machine: npx -y @wuapidev/mcpat https://wuapi.dev/api/mcp
keyWUAPI_API_KEY environment variableAuthorization: Bearer wu_live_... header
needsNode 20 or latera client that sends a custom header

Connect a client

Create a key under API keys in the dashboard. A project key limits the server to one project. Then add the server to your client:

# Local: runs on your machine
claude mcp add wuapi --env WUAPI_API_KEY=wu_live_... -- npx -y @wuapidev/mcp

# Hosted: nothing to install
claude mcp add --transport http wuapi https://wuapi.dev/api/mcp --header "Authorization: Bearer $WUAPI_API_KEY"

The dashboard's MCP server screen (/app/mcp) builds these for you from one of your keys, with read-only and a project as options, one-click install links for Cursor and VS Code and a connection test. Claude Desktop reads claude_desktop_config.json (Settings, Developer, Edit Config). Cursor reads ~/.cursor/mcp.json, or .cursor/mcp.json in a project. VS Code reads .vscode/mcp.json and asks for the key once, so it stays out of the file. Any other client that starts a local command works with the stdio server and WUAPI_API_KEY in its environment; any client that sends a custom header works with the hosted one. Clients that reach remote servers only through OAuth, as some chat apps do, cannot use the hosted endpoint yet.

Configuration

stdio variablehosted headerdoes
WUAPI_API_KEYAuthorization: Bearer ...Required. Your API key.
WUAPI_PROJECTWuapi-ProjectAct inside one project: its id or ext:<externalId>.
WUAPI_MCP_READ_ONLYWuapi-Read-Onlytrue registers only the tools that read. The --read-only flag does the same.
WUAPI_BASE_URLAPI base URL. Default https://api.wuapi.dev. Must be https, or http on localhost.

Tools

areatools
Contextget_current_key
Accountslist_accounts, get_account, get_account_qr_code, create_account, request_pairing_code, reconnect_account, list_proxy_locations
Messagessend_text, send_media, send_location, send_contact, send_poll, reply_to_message, react_to_message, get_message, list_messages, edit_message, delete_message, cancel_message
Chatsmark_chat_read, send_read_receipts, archive_chat, pin_chat, mute_chat
Contactscheck_numbers, lookup_contacts
Groupslist_groups, get_group, create_group, add_group_participants, remove_group_participants, promote_group_participants, demote_group_participants, get_group_invite_link, reset_group_invite_link, leave_group
Storiespost_story
Webhookslist_webhooks, create_webhook, update_webhook, delete_webhook
Projectslist_projects, get_project, create_project
Invitationscreate_invitation, list_invitations, get_invitation, cancel_invitation
Usageget_usage, get_usage_by_project

Every tool declares a JSON schema for its input and returns the API's own object as structured content, without nulls. Tools that read carry readOnlyHint. Tools that delete, revoke, cancel or leave carry destructiveHint and take confirm: true, which the model has to set on purpose and your client shows you before the call: delete_message, cancel_message, remove_group_participants, reset_group_invite_link, leave_group, delete_webhook, cancel_invitation. Sends take an optional idempotencyKey, so a retried call does not send twice. get_account_qr_code returns the QR code as an image the client can show.

Projects, invitations and usage need an organization key. Listing chats, sending a test webhook event and reading the request log have no public endpoint, so they are not tools; they are in the dashboard.

Resources and prompts

Resources a client can attach to the conversation: https://wuapi.dev/openapi.json, https://wuapi.dev/llms-full.txt, https://wuapi.dev/llms.txt and wuapi://webhook-events. Prompts, which Claude Code lists as slash commands: send_message (to, message), setup_webhook (url, events) and invite_customer (customer, externalId, email).

Examples

you askthe tools it calls
Send "Your order shipped" to +584241112233 from the Front desk number.list_accounts, check_numbers, send_text, get_message
Link a new number that exits from Mexico City.list_proxy_locations, create_account, get_account_qr_code
What did +584241112233 write today? Reply that we're on it.list_messages, reply_to_message
Send my webhook the incoming messages at https://example.com/hooks/wuapi.list_webhooks, create_webhook
Invite Northwind (customer_8812) to link their WhatsApp.list_projects, create_project, create_invitation
How much did each customer use last month?get_usage_by_project

Security

  1. The key stays in your client's configuration or header. The server never logs it, never returns it and never puts it in an error. The hosted endpoint stores nothing: it checks the key with GET /v1/me when a client connects, then passes it to the API on each call.
  2. Tool results carry no secrets. A webhook endpoint's signing secret is returned by the API only when the endpoint is created; create_webhook drops it and tells you to reveal it in the dashboard. No tool creates API keys.
  3. Narrow what the model can do: a project key reaches one project, and read-only mode removes every tool that writes.
  4. Media URLs are downloaded by the API, which refuses private and internal addresses; the MCP server fetches nothing on a tool's behalf.
  5. Every argument is checked against the tool's schema before any request. A base URL that is not https is refused, so the key never travels in clear text.
  6. A model can be steered by what it reads, including inbound messages. Keep your client's confirmation prompts on for tools that send or delete.

The source is the npm package @wuapidev/mcp (MIT), built on the TypeScript SDK. It also exports createWuapiMcpServer and a stateless HTTP handler, createWuapiMcpHttpHandler from @wuapidev/mcp/http, to host it yourself.

Accounts and QR linking

An account is one WhatsApp number linked to wuapi as a linked device, the same way WhatsApp Web links to a phone. Link it by scanning a QR code, or by typing a pairing code on the phone (next section).

curl -X POST https://api.wuapi.dev/v1/accounts \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Support line","proxyLocation":{"country":"VE","city":"caracas"}}'

proxyLocation is required: where the account's residential proxy exits (see the next heading). The call returns 201 with the account in initializing. Poll GET /v1/accounts/{accountId}, or listen for account.qr_code_issued, until status is qr_ready: qrCodeUrl then holds a PNG data URL you can put straight into an <img>. The QR code rotates while it waits; always show the latest one. An organization on the Free plan (no paid subscription, no card) connects 1 account and uses it fully: sends, webhooks, groups and every other call. A second one answers 402 upgrade_required until the organization upgrades in Billing. On Free, an account that has not linked within 15 minutes stops its session (link_timeout).

Choose where the number exits

Every account connects through a residential proxy with a sticky exit in the place you choose, so a number keeps a consistent network identity. Pick it from GET /v1/proxy-locations and pass its country and city as proxyLocation when you create the account. Filter with ?country= (an ISO 3166-1 alpha-2 code), or search with ?q=: it matches the city name, the city code, the country name and the ISO code, ignores case and accents, and puts exact and prefix matches first, so q=bogo finds Bogotá and q=sao finds São Paulo. Browse the whole list, no key needed, at wuapi.dev/proxy-locations. Choose where the number's owner actually is. A missing proxyLocation answers 400 invalid_request; a pair that is not in the list answers 400 unsupported_proxy_location. The account shows it back as proxyLocation.

The city is preferred by default: if no residential IP is free in that city when the number needs a new exit, it may get an exit in another city of the same country instead of staying offline, and it stays on that exit while the exit is healthy. Set proxyLocation.strictCity: true to require the exact city instead; the number may then stay offline longer while no IP is free there. The country is always exact. Change either later with PATCH /v1/accounts/{accountId} and proxyLocation ({country, city}, {strictCity}, or both): like any location change, the number gets a new exit IP and reconnects, and WhatsApp may ask the phone to confirm the link again. A connected number changes location at most once every 10 minutes; earlier, the PATCH answers 429 rate_limited with Retry-After.

curl -X PATCH https://api.wuapi.dev/v1/accounts/{accountId} \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"proxyLocation":{"strictCity":true}}'
curl https://api.wuapi.dev/v1/proxy-locations?q=bogo \
  -H "Authorization: Bearer wu_live_..."

Status

statusmeaning
initializingThe session is starting.
qr_readyWaiting for the QR code to be scanned or the pairing code to be entered. qrCodeUrl is set when linking by QR code, pairingCode when linking by code.
authenticatingThe phone accepted the link and is completing it.
readyLinked. You can send. The account is billable from the first time it reaches ready.
disconnectedThe connection dropped. disconnectReason says why. Most reasons reconnect on their own; reconnecting is true while one does.
failedThe session stopped and will not recover on its own. Reconnect to start again.

These disconnectReason values do not reconnect automatically: logged_out (the device was removed from the phone), connection_replaced, temporary_ban and client_outdated. On the Free plan there are two more: link_timeout (not linked within 15 minutes; reconnect for a new QR code) and free_limit_reached (the month's Free limit was reached; the device stays linked and the account reconnects on its own when the month ends, UTC, or when the organization upgrades, with no new QR code). While proxy traffic is paused for an unpaid invoice or the monthly proxy spend cap, running accounts show proxy_paused and reconnect on their own when the pause lifts. After qr_timeout or logged_out, call POST /v1/accounts/{accountId}/reconnect to get a fresh QR code.

reconnecting is true while an account that was ready is reconnecting on its own (status disconnected, initializing or authenticating) within the offline tolerance (currently 3 minutes): messages you send meanwhile wait for it. It is false when the account is ready, when it is down for a reason it does not recover from, and once the tolerance runs out.

Other operations

requesteffect
GET /v1/accountsList accounts. With an organization key, ?projectId= filters by project.
PATCH /v1/accounts/{accountId}{name?, rejectCalls?, rejectCallsMessage?, pacing?, historySync?}. Returns the account. Call settings are covered under Calls, pacing under Rate limits and pacing, historySync under History sync.
POST /v1/accounts/{accountId}/reconnectRestart the session; returns 202 with the account. Produces a new QR code when the link was lost.
POST /v1/accounts/{accountId}/logoutUnlink the phone and stop billing for it, keeping the record. Returns 202 with the account. Reconnect to link again.
DELETE /v1/accounts/{accountId}Unlink, delete and stop billing. Returns 204.
POST /v1/accounts/{accountId}/presence{state}, online or offline: how the account shows to its contacts. Returns 204.
PUT /v1/accounts/{accountId}/disappearing-timer{durationSeconds}: the default disappearing timer for new chats. 0 (off), 86400, 604800 or 7776000. Returns 204.

Proxy traffic is metered per account. Each billable account includes 0.5 GB a month, pooled across the organization; past that it is billed at $0.99 per GB, on the next invoice or sooner, each time the overage not yet invoiced reaches $10.

An organization without a paid subscription is on the Free plan: 1 number, 2,000 messages and 0.5 GB of proxy traffic a month, no card. Messages count sent and received together, and the month is the calendar month in UTC. When either limit is reached the organization's accounts pause (status: disconnected, disconnectReason: free_limit_reached, the device stays linked), and sends, new accounts and reconnects answer 402 free_limit_reached. They reconnect on their own when the month ends, or at once when the organization upgrades in Billing. A count lands once WhatsApp accepts a send, so a burst sent right at the limit can pass it by a few messages.

Proxy spending has two stops. Each organization has a monthly cap on proxy overage ($50 unless an owner or admin changes it in Billing; $0 allows none): at the cap, proxy traffic pauses until the cap is raised or the month ends (UTC). While an invoice is unpaid, it pauses until the invoice is paid. During a pause every running account shows status: disconnected with disconnectReason: proxy_paused (the device stays linked, and account.disconnected fires), sends, stories, new accounts and reconnects answer 402 payment_required or 402 proxy_spend_cap_reached, and messages already queued wait up to 24 hours for the pause to lift, then fail with that code. When it lifts, the accounts reconnect on their own with no new QR code and what waited is sent.

Linking with a pairing code

When the person linking cannot scan a QR code, for example because wuapi runs on the same phone they would scan with, link with an 8-character code instead. On the phone: Settings, Linked devices, Link a device, Link with phone number instead, then type the code.

curl -X POST https://api.wuapi.dev/v1/accounts \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Support line","proxyLocation":{"country":"VE","city":"caracas"},"pairingPhone":"+584121234567"}'

phone is the number being linked, in E.164 or digits. With pairingPhone on create, wuapi requests the code once the session is up and exposes it as pairingCode (and pairingCodeExpiresAt) on the account instead of qrCodeUrl, and fires account.pairing_code_issued. POST .../pairing-code returns 201 with the code directly. A code lives about 160 seconds, WhatsApp's own lifetime for it; ask for a new one after it expires. Asking for a code on an account that is already ready answers 409 already_linked.

Sending messages

POST /v1/messages sends one message from one account to a contact, a group or a channel. The account must be ready. An account that dropped from ready for a reconnect less than the offline tolerance ago (currently 3 minutes) still takes the message: it waits for the account and is sent once the account is back, or fails with account_offline if the account is not back within the tolerance or goes down for good.

The body is one of twelve shapes, chosen by type. Each type requires its own field; the fields in the first table work with every type.

fieldnotes
accountIdRequired. The account to send from.
toRequired. A contact (+584241112233, plain digits, lid:..., or the @username of a contact the account already chats with), a group id ending in @g.us, or a channel id ending in @newsletter to post to a channel the account administers. To a channel: text, image, video or document, with no replyToMessageId, mentions or mentionAll.
typeRequired, one of the types below. A body without type is sent as text.
replyToMessageIdA wuapi message ID in the same chat, to quote it. Works with every type.
mentionsContacts to mention, up to 256.
mentionAlltrue mentions every participant. Groups only.
forwardedtrue marks the message as forwarded.
disappearingSeconds0, 86400, 604800 or 7776000. Match the chat's timer.
metadataYour own string key-value pairs, up to 50 keys, returned on the message and in webhooks.
typerequiresalso takes
texttext: up to 4,096 characters, not blank.linkPreview: {url, title, description?, thumbnailBase64?}. We do not fetch previews; you provide them. url must be HTTPS.
image, videomedia: {url, mimeType?, filename?}. url must be HTTPS; our servers fetch it. A missing mimeType is guessed from the URL.text as the caption. viewOnce: true: the recipient can open it once. Video only: media.gifPlayback: true plays it as a GIF.
audio, voicemedia, as above. voice sends the audio as a voice note.text, viewOnce.
document, stickermedia, as above.text as the caption.
locationlocation: {latitude, longitude, name?, address?}.
contactcontact: {name, phone}.
contactscontacts: 2 to 20 {name, phone} cards in one message.
pollpoll: {name, options, selectableCount?}. 2 to 12 unique options, each up to 100 characters.poll.selectableCount: 0 (default) lets voters pick any number.
calendar_eventcalendarEvent: {name, startsAt}. Times are ISO 8601.calendarEvent.description, endsAt, location, callType, allowExtraGuests.

Send an Idempotency-Key header with every send you might retry. The same key within 24 hours returns the original response, with Idempotent-Replayed: true, instead of a second message.

curl https://api.wuapi.dev/v1/messages \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "to": "+584241112233",
    "type": "image",
    "text": "Invoice attached",
    "media": {
      "url": "https://example.com/invoice.png",
      "mimeType": "image/png"
    }
  }'
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "+584241112233",
  "type": "location",
  "location": {
    "latitude": 10.4806,
    "longitude": -66.9036,
    "name": "Office"
  }
}

Polls, calendar events and the other types

{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "120363041234567890@g.us",
  "type": "poll",
  "poll": {
    "name": "Team dinner?",
    "options": [
      "Thursday",
      "Friday"
    ],
    "selectableCount": 1
  }
}
{
  "object": "message",
  "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
  "projectId": null,
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "chatId": "+584241112233",
  "chatType": "direct",
  "direction": "outbound",
  "source": "api",
  "from": "+584121234567",
  "to": "+584241112233",
  "profileName": null,
  "username": null,
  "type": "text",
  "text": "Your order has shipped.",
  "media": null,
  "location": null,
  "contact": null,
  "contacts": null,
  "poll": null,
  "calendarEvent": null,
  "mentions": [],
  "forwarded": false,
  "viewOnce": false,
  "starred": false,
  "replyToMessageId": null,
  "status": "queued",
  "error": null,
  "metadata": {
    "orderId": "4417"
  },
  "sentAt": null,
  "editedAt": null,
  "deletedAt": null,
  "createdAt": "2026-09-24T14:02:11.000Z",
  "updatedAt": "2026-09-24T14:02:11.000Z"
}
  1. type: "voice" sends an audio as a voice note. We do not transcode: send ogg/opus.
  2. calendarEvent.callType (audio or video) marks the event as a scheduled WhatsApp call. The call link itself is created by WhatsApp on the phone, so joinUrl stays null on events you send.
  3. A poll's votes arrive as poll.voted webhooks, and the stored poll keeps the tally in poll.options[].voteCount.

The send returns 202 with the message queued. Each number sends one message at a time, in the order they reach its queue; an account that turned its pacing on holds a burst in the queue and sends it at that pace (see Sending safely). A message ends failed with error.code not_on_whatsapp when the recipient has no WhatsApp, rate_limited when, with a pacing cap on, it waited in the queue past the account's pacing.queueTimeoutMinutes (an hour by default) without a free slot, account_offline when the account stayed offline past the offline tolerance (or went down for good) while the message waited, payment_required or proxy_spend_cap_reached when it was queued before proxy traffic paused and the pause lasted more than 24 hours, cancelled when you deleted it before it was sent, or send_failed for other errors. A connection that drops while a message is being sent does not fail it: the message waits for the account and is sent again with the same WhatsApp message id, so it is never delivered twice.

media.url must be a public http or https URL that answers with the file itself: our servers download it once, following up to 5 redirects, within 60 seconds and up to 100 MB, as wuapi-media-fetcher/1.0 (+https://wuapi.dev). Private and internal addresses are refused, and hosts with hotlink protection or bot filters may refuse the download. A download that fails ends the message failed with send_failed and a message that names the host and its answer, for example fetch media from upload.wikimedia.org: HTTP 403. When a site refuses, host the file on storage you control.

There are no message templates, buttons or list messages to send. Those are features of the official WhatsApp Business Platform.

Reading, editing and deleting

requesteffect
GET /v1/messagesList messages, newest first. Filter with accountId, chatId, direction and, with an organization key, projectId.
GET /v1/messages/{messageId}One message.
POST /v1/messages/{messageId}/react{emoji}. An empty string removes your reaction. Returns 204.
PATCH /v1/messages/{messageId}{text}. Edit an outbound text message. WhatsApp only accepts edits for about 15 minutes after sending and answers with an error after that. Returns the message with editedAt. A text message still queued is sent with the new text instead (no editedAt, no message.edited).
DELETE /v1/messages/{messageId}Outbound messages only. For everyone by default; ?forEveryone=false removes it from the linked devices only. A message still queued is cancelled instead: it never goes out, ends failed with error.code cancelled, and message.failed fires. Returns 204. While a queued message is being handed to WhatsApp, DELETE and PATCH answer 409 message_sending for a few seconds.
POST /v1/messages/{messageId}/starStar the message, on the phone too. /unstar removes the star. Both return the message.
POST /v1/messages/{messageId}/vote{options}: vote in a poll by option name, in a poll you sent or received. [] retracts the vote. Returns the poll message with the tally.
POST /v1/messages/{messageId}/labels{labelId}: label the message. DELETE /v1/messages/{messageId}/labels/{labelId} removes it. WhatsApp Business only. Both return 204.
POST /v1/accounts/{accountId}/chats/{chatId}/read{messageIds?}: send read receipts (blue ticks). Without messageIds, every unread inbound message we store for that chat, up to 500 per call. Returns a chat_read with messageCount.
curl -X PATCH https://api.wuapi.dev/v1/messages/j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"text":"Your order ships tomorrow, not today."}'

Edits and poll votes are messages in WhatsApp's eyes, so they go through the same pacing as a send. Vote options are checked against the stored poll: an unknown name, a repeated one, or more than selectableCount answers 400. Deletions and edits made by the other side arrive as message.deleted and message.edited; the stored row is kept, with deletedAt or editedAt set.

Delete for me is not available. WhatsApp's libraries only read that change; they cannot write it. Delete for everyone works.

Chats

Chat state is synced across the phone and every linked device, so these changes show up on the phone too. {chatId} is a contact, a group id or a channel id. Each action returns 204.

request (under /v1/accounts/{accountId})effect
POST /chats/{chatId}/presence{state}: typing, recording or paused. Shows or clears the indicator in that chat.
POST /chats/{chatId}/archive, /unarchiveArchive or unarchive.
POST /chats/{chatId}/pin, /unpinPin or unpin.
POST /chats/{chatId}/mute, /unmutemute takes {durationSeconds?}. Without a duration, or 0, it mutes until unmuted.
POST /chats/{chatId}/mark-read, /mark-unreadMark the chat read or unread, as the phone's chat list does. Sends no receipts; use /read for those.
DELETE /chats/{chatId}?deleteMedia=true also deletes the chat's media. Messages stored in wuapi are kept.
PUT /chats/{chatId}/disappearing-timer{durationSeconds}: 0 (off), 86400, 604800 or 7776000.
POST /chats/{chatId}/labels{labelId}. DELETE /chats/{chatId}/labels/{labelId} takes it off. WhatsApp Business only.
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/chats/+584241112233/mute \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"durationSeconds":28800}'

Changes made on the phone arrive as chat.updated, and a contact typing arrives as chat.presence_updated. Changes replayed by the full sync right after linking are not sent: that would be thousands of events describing the past.

Stories

Post a story (a WhatsApp Status) from an account. It is queued and paced like a send and stored as a message with chatId stories and chatType story.

curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/stories \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"type":"text","text":"Closed today for inventory.","backgroundColor":"#1F2937","font":1}'

type is text, image or video. backgroundColor is #RRGGBB and font one of 0, 1, 2, 6, 7, 8, 9 or 10, WhatsApp's own values. Idempotency-Key works as on a send. Returns 202 with the message. Who sees the story follows the account's story privacy: read it with GET .../privacy/stories.

Posting only. Your contacts' stories are not received.

Contacts

request (under /v1/accounts/{accountId})effect
POST /contacts/check{phones}, up to 50. Which numbers have WhatsApp: a list of contact_check.
POST /contacts/lookup{contactIds}, up to 50. About text, picture id, business name and device count: a list of contact.
GET /contacts/{contactId}/pictureA picture with url. ?preview=true for the thumbnail. 404 picture_not_found when there is none, or it is hidden from you.
GET /contacts/{contactId}/business-profileAddress, email, categories and hours of a business account. 404 business_profile_not_found otherwise.
POST /contacts/{contactId}/subscribe-presenceReceive contact.presence_updated when that contact goes online or offline. Returns 204.
POST /contacts/{contactId}/block, /unblockBlock or unblock. Returns 204.
GET /blocklistA list of blocked_contact: contactId as WhatsApp keeps it (often a lid:), plus phone and lid when the account knows them (null otherwise).
GET /contact-linkThe account's own contact link. POST /contact-link/reset revokes it and returns a new one.
POST /links/resolve{kind, code}: resolve a contact link (contact) or a business message link (business) to the contact behind it. 404 link_not_found when it does not exist. 400 not_supported when WhatsApp does not offer link resolution to this account.
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/contacts/check \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"phones":["+584241112233","+584141234567"]}'

Checking a number, looking up contacts and resolving a link only read, so they keep working for a suspended project. Changes to a contact's about text, username and picture arrive as contact.updated and contact.picture_updated. username is the contact's WhatsApp username when this account knows it; a check never asks WhatsApp for it, a lookup does.

Profile and privacy

request (under /v1/accounts/{accountId})effect
PATCH /profile{about?, name?}. About text up to 139 characters, display name up to 25. Returns 204: WhatsApp does not let us read it back.
PUT /profile/picture{url} (HTTPS) or {base64}, a JPEG. Returns a picture with its id.
DELETE /profile/pictureRemove the picture. Returns 204.
GET /privacyThe account's privacy_settings.
PATCH /privacyAny of the settings below, for example {readReceipts: "none"}. Returns the settings after the change.
GET /privacy/storiesWho sees the account's stories: a story_privacy with lists: [{type, contactIds, default}].
curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/privacy \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"readReceipts":"none"}'
settingvalues
groupAddall, contacts, contact_blacklist, none
lastSeenall, contacts, contact_blacklist, none
storiesall, contacts, contact_blacklist, none
profileall, contacts, contact_blacklist, none
readReceiptsall, none
onlineall, match_last_seen
callAddall, known
messagesall, contacts

Each setting is one change on WhatsApp, so a PATCH with several settings takes a moment longer than one with a single setting.

Groups

Group operations run live against WhatsApp through the account, so the account must be ready. Group IDs end in @g.us; use one as to to send to the group.

request (under /v1/accounts/{accountId})effect
GET /groupsGroups the account belongs to, as a paginated list.
POST /groups{name, participants, community?}. participants are contacts. Returns 201 with the group.
GET /groups/{groupId}One group with its participants.
PATCH /groups/{groupId}{name?, description?, announce?, locked?, joinApproval?, memberAddMode?}. announce: only admins send. locked: only admins edit the info. memberAddMode: admins or all_members. Returns the group.
POST /groups/{groupId}/participants/add, /remove, /promote, /demote{contactIds}. Returns a list with a participant_result per contact.
GET /groups/{groupId}/join-requestsPending join requests, when join approval is on.
POST /groups/{groupId}/join-requests/approve, /reject{contactIds}. Returns a list of participant_result.
PUT /groups/{groupId}/picture{url} (HTTPS) or {base64}, a JPEG. Returns a picture. DELETE removes it.
GET /groups/{groupId}/invite-linkA group_invite_link with url. POST /groups/{groupId}/invite-link/reset revokes it and returns a new one.
POST /groups/join{code}, the code or the full link: join by invite. Returns a group_join with groupId. 404 invite_not_found for a dead link.
GET /groups/invites/{code}Preview the group behind an invite code before joining.
POST /groups/{groupId}/leaveLeave the group. Returns 204.
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/groups/120363041234567890@g.us/participants/add \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"contactIds":["+584241112233"]}'

When a contact cannot be added directly because of their privacy settings, the result carries an inviteCode you can send them instead. Membership and setting changes arrive as group.updated, join requests as group.join_requested and group.join_request_revoked.

Communities

A community is a group that holds other groups, its subgroups. Create one with community: true on the groups endpoint; participants may then be empty. It comes back with community: true.

curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/groups \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Riverside projects","community":true}'
request (under /v1/accounts/{accountId}/groups/{groupId})effect
GET /subgroupsThe community's groups, as a list of subgroup (id, name, default).
POST /subgroups{groupId}: link an existing group. Returns 204.
DELETE /subgroups/{subgroupId}Unlink a group. Returns 204.
GET /community-participantsEveryone across the community's groups, as a list of community_participant.

Channels

WhatsApp channels are one-way broadcasts. Channel IDs end in @newsletter. Read, follow and react with any account; post to a channel the account administers with POST /v1/messages and to set to the channel id.

request (under /v1/accounts/{accountId})effect
GET /channelsChannels the account follows or owns, as a list.
POST /channels{name, description?, pictureBase64?}: create a channel. Returns 201 with the channel.
GET /channels/{channelId}One channel.
GET /channels/invites/{code}A channel by its invite code.
POST /channels/{channelId}/follow, /unfollowFollow or unfollow. Returns 204.
POST /channels/{channelId}/mute, /unmuteMute or unmute. Returns 204.
GET /channels/{channelId}/messagesRecent posts, newest first, as a list of channel_message. Paged with limit and cursor like every list.
POST /channels/{channelId}/messages/{channelMessageId}/react{emoji}. An empty string removes it. Returns 204.
POST /channels/{channelId}/mark-viewed{channelMessageIds}, 1 to 100: mark posts as seen. Returns 204.
curl -X POST https://api.wuapi.dev/v1/messages \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"accountId":"k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr","to":"120363198765432109@newsletter","type":"text","text":"Doors open at 8."}'

New posts in followed channels arrive as channel.message_received and new view and reaction counts as channel.message_updated; neither is stored. Follow, unfollow and mute changes arrive as channel.updated.

Calls

wuapi does not answer or place calls. It tells you about them and can turn them down. An incoming call fires call.received; when it ends, call.ended. Both carry a call object.

curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"rejectCalls":true,"rejectCallsMessage":"We do not take calls on this number. Write to us here."}'

With rejectCalls: true every incoming call is rejected as it rings, and rejectCallsMessage, up to 1,000 characters, is sent to the caller. The reply goes through the account's pacing and is skipped for group calls. To reject one call yourself, pass the id and from of the call in call.received.

Labels

Labels exist only on WhatsApp Business. On a number linked from the consumer app these routes answer 400 not_supported.

requesteffect
PUT /v1/accounts/{accountId}/labels/{labelId}{name, color?}. Creates or edits a label; color is WhatsApp's palette index, 0 to 19. Returns the label.
DELETE /v1/accounts/{accountId}/labels/{labelId}Deletes the label. Returns 204.
POST /v1/accounts/{accountId}/chats/{chatId}/labels{labelId}: put a label on a chat. DELETE .../chats/{chatId}/labels/{labelId} takes it off.
POST /v1/messages/{messageId}/labels{labelId}: the same for one message. DELETE /v1/messages/{messageId}/labels/{labelId} takes it off.
curl -X PUT https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/labels/3 \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Paid","color":5}'

There is no endpoint that lists labels. After linking, WhatsApp sends the existing labels, and every later change, as label.updated events: keep your own copy from those.

History sync

Right after a number links, WhatsApp sends the linked device part of the chat history, the same way WhatsApp Web fills in old chats. History import is off by default: an account stores only the messages sent and received after it links. Turn it on per account with historySync: "recent" on POST /v1/accounts (or PATCH before the number links, or the checkbox in the dashboard). wuapi then stores that history as messages with source history, so GET /v1/messages?accountId=...&chatId=... returns old conversations as well as new ones.

curl -X POST https://api.wuapi.dev/v1/accounts \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Support line","proxyLocation":{"country":"VE","city":"caracas"},"historySync":"recent"}'
  1. WhatsApp sends history once, right after linking. Changing historySync applies to the next link: a number that is already linked gets no history, not even after a reconnect.
  2. With historySync none nothing is stored and history.synced never fires. Contact names and live messages work the same either way.
  3. Invitations take historySync too; the account the invitee links gets it. It is none unless you set it.
  4. History arrives in pushes of at most 500 messages. Each push fires one history.synced with counts, never one webhook per message.
  5. Historical messages carry no media: type says what it was and media is null.
  6. Messages already stored are skipped and counted in duplicateCount.
  7. History does not count toward sentMessageCount or receivedMessageCount in usage.
  8. How much history WhatsApp sends is decided by WhatsApp and the phone. There is no endpoint to ask for more.

Sticker packs, orders and bots

request (under /v1/accounts/{accountId})returns
GET /sticker-packs/{stickerPackId}A sticker_pack with its stickers. 404 sticker_pack_not_found.
GET /orders/{orderId}?token=An order a customer sent from a catalog: products, prices and totals in cents. The token comes with the order message. 404 order_not_found.
GET /botsWhatsApp's AI bot directory, as a list of bot. 400 not_supported when WhatsApp does not offer it to this account (WhatsApp's AI assistant is not available in every country or on every account).
curl https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/orders/ORDER_ID?token=ORDER_TOKEN \
  -H "Authorization: Bearer wu_live_..."

Webhooks

Create up to five HTTPS webhook endpoints per organization, plus five per project, in the dashboard or with POST /v1/webhook-endpoints {url, events, projectId?}. The response includes the signing secret; it is shown once. POST /v1/webhook-endpoints/{webhookEndpointId}/rotate-secret issues a new one and returns it.

const endpoint = await wuapi.webhookEndpoints.create({
  url: "https://example.com/webhooks/wuapi",
  events: ["message.received", "message.failed", "account.disconnected"],
})
console.log(endpoint.secret) // whsec_..., returned only on creation and rotation
{
  "id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
  "object": "event",
  "type": "message.received",
  "createdAt": "2026-09-24T14:02:11.000Z",
  "organizationId": "w82t6y1u5i9o3p7a2s6d0f4g8h2j6k1l",
  "projectId": null,
  "data": {
    "object": {
      "object": "message",
      "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
      "...": "..."
    }
  }
}

Every event shares that envelope. data.object is the resource in the same shape the REST API returns it, so the code that reads a message from GET /v1/messages/{messageId} also reads it from message.received. message.edited and invitation.status_changed add data.previousAttributes with the values before the change. projectId is the project the event belongs to, or null. An organization endpoint receives every project's events; an endpoint created for a project receives only that project's. The catalog is in Events.

Delivery

We POST the JSON payload with a 10 second timeout, without following redirects. Any 2xx is a success. Anything else is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours: six attempts in total. The dashboard (Logs, Webhooks) keeps every delivery for 30 days with each attempt's status, latency and the start of a non-2xx response body; from there you can resend a delivery (a new delivery with the same id) or send a webhook.test event to an endpoint. Requests carry Wuapi-Event-Id and Wuapi-Event-Type headers. Deliveries can arrive more than once and out of order: handle events idempotently using id.

Linked accounts drop their connection and reconnect by themselves every few minutes, usually within a few seconds. Those blips are not events: account.disconnected fires only when the account is still not ready after the reconnect grace (currently 20 seconds), and a reconnect inside it fires neither that nor account.connected: every account.connected after a drop answers an account.disconnected you received. A drop the account does not recover from on its own (logged_out, for example) fires at once. The account in an account.disconnected for a drop it is still recovering from carries reconnecting: true. Sends made during a blip wait for the account.

To alert on your side, key off these two events: on account.disconnected, alert right away when disconnectReason is one the session does not recover from (logged_out, connection_replaced, temporary_ban, or status failed or qr_ready); otherwise start a timer for as long as you can live with the number offline, and cancel it on account.connected for the same account. You do not need that to hear about it yourself: the dashboard's notifications (Settings, Notifications) email the owner and admins, show in the bell and can post to a Slack incoming webhook when a number stays offline past a threshold you choose (10 minutes by default), needs to be linked again, or a webhook endpoint keeps failing. Those never go through your webhook endpoints.

Verifying the signature

Each request carries Wuapi-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> keyed with the endpoint secret. Compute it over the raw body, compare in constant time, and reject timestamps older than a few minutes.

import { verifyWebhook, WebhookVerificationError } from "@wuapidev/sdk"

export async function POST(request: Request) {
  const rawBody = await request.text()
  try {
    const event = await verifyWebhook(
      rawBody,
      request.headers.get("wuapi-signature"),
      process.env.WUAPI_WEBHOOK_SECRET!,
    )
    switch (event.type) {
      case "message.received":
        console.log(event.data.object.from, event.data.object.text)
        break
      case "account.disconnected":
        console.log(event.data.object.id, event.data.object.disconnectReason)
        break
    }
    return new Response(null, { status: 204 })
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 })
    throw err
  }
}

Events

Every event type, with the data it carries. data.object names its type in its own object field: a resource such as account or message, or an object that only events carry, such as poll_vote or call. Contacts are E.164 numbers, or lid: ids when WhatsApp hides the number.

35 event types

Accounts

›account.qr_code_issuedThe account entered qr_ready and has a QR code to scan. Fires on the transition, not on every refresh: read the current qrCodeUrl from GET /v1/accounts/{accountId}.
{
  "object": {
    "object": "account",
    "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "projectId": null,
    "name": "Support line",
    "status": "qr_ready",
    "reconnecting": false,
    "phone": null,
    "profileName": null,
    "proxyLocation": {
      "country": "VE",
      "city": "caracas",
      "strictCity": false
    },
    "qrCodeUrl": "data:image/png;base64,iVBORw0KGgo...",
    "pairingCode": null,
    "pairingCodeExpiresAt": null,
    "billable": false,
    "disconnectReason": null,
    "lastError": null,
    "rejectCalls": false,
    "rejectCallsMessage": null,
    "pacing": {
      "messagesPerMinute": 0,
      "firstContactPerMinute": 0,
      "typing": {
        "enabled": false,
        "minMs": 800,
        "maxMs": 6000,
        "charsPerSecond": 25
      },
      "queueTimeoutMinutes": 60,
      "custom": false
    },
    "metadata": {},
    "linkedAt": null,
    "lastConnectedAt": null,
    "createdAt": "2026-09-20T09:10:02.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›account.pairing_code_issuedA new pairing code was issued for an account linking by phone number. Fires only when the code changes. The code is pairingCode.
{
  "object": {
    "object": "account",
    "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "projectId": null,
    "name": "Support line",
    "status": "qr_ready",
    "reconnecting": false,
    "phone": null,
    "profileName": null,
    "proxyLocation": {
      "country": "VE",
      "city": "caracas",
      "strictCity": false
    },
    "qrCodeUrl": null,
    "pairingCode": "WZYX-4K2Q",
    "pairingCodeExpiresAt": "2026-09-24T14:04:51.000Z",
    "billable": false,
    "disconnectReason": null,
    "lastError": null,
    "rejectCalls": false,
    "rejectCallsMessage": null,
    "pacing": {
      "messagesPerMinute": 0,
      "firstContactPerMinute": 0,
      "typing": {
        "enabled": false,
        "minMs": 800,
        "maxMs": 6000,
        "charsPerSecond": 25
      },
      "queueTimeoutMinutes": 60,
      "custom": false
    },
    "metadata": {},
    "linkedAt": null,
    "lastConnectedAt": null,
    "createdAt": "2026-09-20T09:10:02.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›account.connectedThe account reached ready: the first link, or back after account.disconnected. A reconnect within the reconnect grace fires neither. Cancel an offline timer you started on account.disconnected here.
{
  "object": {
    "object": "account",
    "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "projectId": null,
    "name": "Support line",
    "status": "ready",
    "reconnecting": false,
    "phone": "+584121234567",
    "profileName": "Acme Support",
    "proxyLocation": {
      "country": "VE",
      "city": "caracas",
      "strictCity": false
    },
    "qrCodeUrl": null,
    "pairingCode": null,
    "pairingCodeExpiresAt": null,
    "billable": true,
    "disconnectReason": null,
    "lastError": null,
    "rejectCalls": false,
    "rejectCallsMessage": null,
    "pacing": {
      "messagesPerMinute": 0,
      "firstContactPerMinute": 0,
      "typing": {
        "enabled": false,
        "minMs": 800,
        "maxMs": 6000,
        "charsPerSecond": 25
      },
      "queueTimeoutMinutes": 60,
      "custom": false
    },
    "metadata": {},
    "linkedAt": "2026-09-20T09:12:40.000Z",
    "lastConnectedAt": "2026-09-24T14:02:11.000Z",
    "createdAt": "2026-09-20T09:10:02.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›account.disconnectedThe connection dropped. disconnectReason says why. A drop the session does not recover from on its own (logged_out, for example) fires at once. One it reconnects from by itself fires only if the account is not ready again within the reconnect grace (currently 20 seconds), and status may then read initializing or authenticating while it keeps retrying, with reconnecting: true. To alert on your side, alert at once for a reason the session does not recover from, and otherwise start a timer that account.connected cancels.
{
  "object": {
    "object": "account",
    "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "projectId": null,
    "name": "Support line",
    "status": "disconnected",
    "reconnecting": false,
    "phone": "+584121234567",
    "profileName": "Acme Support",
    "proxyLocation": {
      "country": "VE",
      "city": "caracas",
      "strictCity": false
    },
    "qrCodeUrl": null,
    "pairingCode": null,
    "pairingCodeExpiresAt": null,
    "billable": false,
    "disconnectReason": "logged_out",
    "lastError": null,
    "rejectCalls": false,
    "rejectCallsMessage": null,
    "pacing": {
      "messagesPerMinute": 0,
      "firstContactPerMinute": 0,
      "typing": {
        "enabled": false,
        "minMs": 800,
        "maxMs": 6000,
        "charsPerSecond": 25
      },
      "queueTimeoutMinutes": 60,
      "custom": false
    },
    "metadata": {},
    "linkedAt": "2026-09-20T09:12:40.000Z",
    "lastConnectedAt": "2026-09-24T14:02:11.000Z",
    "createdAt": "2026-09-20T09:10:02.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›account.failedThe session stopped and will not recover on its own. Reconnect to start again.
{
  "object": {
    "object": "account",
    "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "projectId": null,
    "name": "Support line",
    "status": "failed",
    "reconnecting": false,
    "phone": "+584121234567",
    "profileName": "Acme Support",
    "proxyLocation": {
      "country": "VE",
      "city": "caracas",
      "strictCity": false
    },
    "qrCodeUrl": null,
    "pairingCode": null,
    "pairingCodeExpiresAt": null,
    "billable": true,
    "disconnectReason": "connect_failed",
    "lastError": null,
    "rejectCalls": false,
    "rejectCallsMessage": null,
    "pacing": {
      "messagesPerMinute": 0,
      "firstContactPerMinute": 0,
      "typing": {
        "enabled": false,
        "minMs": 800,
        "maxMs": 6000,
        "charsPerSecond": 25
      },
      "queueTimeoutMinutes": 60,
      "custom": false
    },
    "metadata": {},
    "linkedAt": "2026-09-20T09:12:40.000Z",
    "lastConnectedAt": "2026-09-24T14:02:11.000Z",
    "createdAt": "2026-09-20T09:10:02.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}

Messages

›message.receivedA contact sent a message to a connected account. Reactions arrive here too, with type reaction, the emoji in text and the target in replyToMessageId.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "inbound",
    "source": "contact",
    "from": "+584241112233",
    "to": "+584121234567",
    "profileName": "Maria",
    "username": "maria.gomez",
    "type": "text",
    "text": "Is my order on the way?",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "received",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:10.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›message.sentWhatsApp accepted an outbound message. Also fires for a message sent from the phone itself, with source phone, and for stories and channel posts.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "sent",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:12.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›message.deliveredThe recipient's device received the message.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "delivered",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:12.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›message.readThe recipient read the message, or played a voice note.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "read",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:12.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›message.failedAn outbound message could not be sent. error.code is not_on_whatsapp, rate_limited, account_offline, cancelled (deleted while still queued) or send_failed.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "failed",
    "error": {
      "code": "not_on_whatsapp",
      "message": "The recipient is not on WhatsApp."
    },
    "metadata": {},
    "sentAt": null,
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›message.editedA stored message was edited, by the contact or by you. The row is updated in place, editedAt is set, and previousAttributes.text holds the text before the edit.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "inbound",
    "source": "contact",
    "from": "+584241112233",
    "to": "+584121234567",
    "profileName": "Maria",
    "username": null,
    "type": "text",
    "text": "Is my order on the way? It is #4417.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "received",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:10.000Z",
    "editedAt": "2026-09-24T14:05:02.000Z",
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  },
  "previousAttributes": {
    "text": "Is my order on the way?"
  }
}
›message.deletedA stored message was deleted for everyone. The row is kept, its content is cleared and deletedAt is set.
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": null,
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "read",
    "error": null,
    "metadata": {},
    "sentAt": null,
    "editedAt": null,
    "deletedAt": "2026-09-24T14:06:40.000Z",
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›poll.votedSomeone voted in a poll. poll is the poll message with its new tally, or null when wuapi does not store the poll. A vote on a poll the account never saw arrives with an empty options list.
{
  "object": {
    "object": "poll_vote",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "120363041234567890@g.us",
    "messageId": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "voterId": "+584241112233",
    "options": [
      "Friday"
    ],
    "votedAt": "2026-09-24T14:07:12.000Z",
    "poll": {
      "object": "message",
      "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
      "projectId": null,
      "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
      "chatId": "120363041234567890@g.us",
      "chatType": "group",
      "direction": "outbound",
      "source": "api",
      "from": "+584121234567",
      "to": "120363041234567890@g.us",
      "profileName": null,
      "username": null,
      "type": "poll",
      "text": null,
      "media": null,
      "location": null,
      "contact": null,
      "contacts": null,
      "poll": {
        "name": "Team dinner?",
        "options": [
          {
            "name": "Thursday",
            "voteCount": 1
          },
          {
            "name": "Friday",
            "voteCount": 3
          }
        ],
        "selectableCount": 1,
        "voterCount": 4
      },
      "calendarEvent": null,
      "mentions": [],
      "forwarded": false,
      "viewOnce": false,
      "starred": false,
      "replyToMessageId": null,
      "status": "read",
      "error": null,
      "metadata": {},
      "sentAt": null,
      "editedAt": null,
      "deletedAt": null,
      "createdAt": "2026-09-24T14:02:11.000Z",
      "updatedAt": "2026-09-24T14:02:11.000Z"
    }
  }
}

Groups

›group.joinedThe account was added to a group or created one.
{
  "object": {
    "object": "group",
    "id": "120363041234567890@g.us",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "name": "Night shift",
    "description": "Handover notes and schedules.",
    "ownerId": "+584121234567",
    "community": false,
    "locked": false,
    "announce": false,
    "participants": [
      {
        "contactId": "+584121234567",
        "name": "Acme Support",
        "role": "owner"
      },
      {
        "contactId": "+584241112233",
        "name": "Maria",
        "role": "member"
      }
    ],
    "createdAt": "2026-09-01T12:00:00.000Z"
  }
}
›group.updatedParticipants, admins, name, description or settings of a group changed. changes lists what moved; fields that did not change are null or empty.
{
  "object": {
    "object": "group_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "groupId": "120363041234567890@g.us",
    "actorId": "+584241112233",
    "added": [
      "+584141234567"
    ],
    "removed": [],
    "promoted": [],
    "demoted": [],
    "name": null,
    "description": null,
    "locked": null,
    "announce": null,
    "changes": [
      "participants_added"
    ],
    "changedAt": "2026-09-24T14:07:12.000Z"
  }
}
›group.join_requestedSomeone asked to join a group with join approval on. Approve with POST .../groups/{groupId}/join-requests/approve.
{
  "object": {
    "object": "group_join_request",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "groupId": "120363041234567890@g.us",
    "contactId": "+584241112233",
    "requestedAt": "2026-09-24T14:07:12.000Z"
  }
}
›group.join_request_revokedSomeone withdrew their request to join a group.
{
  "object": {
    "object": "group_join_request",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "groupId": "120363041234567890@g.us",
    "contactId": "+584241112233",
    "requestedAt": "2026-09-24T14:07:12.000Z"
  }
}

Chats and contacts

›chat.updatedA chat was archived, pinned, muted, marked read, deleted or cleared, or a message was starred, on any linked device. change is archive, pin, mute, read, delete, clear or star; value is the new value.
{
  "object": {
    "object": "chat_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "change": "mute",
    "value": true,
    "messageId": null,
    "mutedUntil": "2026-09-25T14:00:00.000Z"
  }
}
›chat.presence_updatedA contact is typing, recording a voice note, or stopped, in a chat. state is typing, recording or paused.
{
  "object": {
    "object": "chat_presence",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "contactId": "+584241112233",
    "state": "typing"
  }
}
›contact.presence_updatedA contact you subscribed to went online or offline. Subscribe with POST .../contacts/{contactId}/subscribe-presence.
{
  "object": {
    "object": "contact_presence",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "contactId": "+584241112233",
    "online": false,
    "lastSeenAt": "2026-09-24T14:05:00.000Z"
  }
}
›contact.picture_updatedA contact or group changed or removed its picture. chatId is the contact or the group.
{
  "object": {
    "object": "picture_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "pictureId": "1790258832",
    "removed": false,
    "changedBy": null,
    "changedAt": "2026-09-24T14:07:12.000Z"
  }
}
›contact.updatedA contact changed their about text, or their WhatsApp username became known or changed (then username and lid are set and about is null). Fields WhatsApp did not send are null.
{
  "object": {
    "object": "contact",
    "id": "+584241112233",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "lid": null,
    "username": null,
    "about": "At the office until 6",
    "pictureId": null,
    "businessName": null,
    "deviceCount": null
  }
}
›blocklist.updatedThe account's blocklist changed. Each change names the contact, with phone and lid when the account knows them. WhatsApp often says only that the list changed; we then compare it with the list we saw before and name the changes. refetch: true (with no changes) means there was no earlier list to compare with: read GET .../blocklist.
{
  "object": {
    "object": "blocklist_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "changes": [
      {
        "contactId": "+584241112233",
        "action": "block",
        "phone": "+584241112233",
        "lid": "lid:201843727138927"
      }
    ],
    "refetch": false
  }
}
›label.updatedWhatsApp Business only. A label was created, edited or deleted (kind label), or put on or taken off a chat (chat) or a message (message). The label list itself arrives this way after linking.
{
  "object": {
    "object": "label_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "kind": "chat",
    "labelId": "3",
    "name": null,
    "color": null,
    "deleted": false,
    "chatId": "+584241112233",
    "messageId": null,
    "labeled": true
  }
}

Calls

›call.receivedAn incoming call. Reject it with POST .../calls/{callId}/reject, or let rejectCalls do it for you.
{
  "object": {
    "object": "call",
    "id": "4B2F9A0C1D3E5F7A",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "from": "+584241112233",
    "video": false,
    "groupId": null,
    "endReason": null,
    "startedAt": "2026-09-24T14:10:00.000Z",
    "endedAt": null
  }
}
›call.endedA call ended or was rejected. endReason says how, when WhatsApp tells.
{
  "object": {
    "object": "call",
    "id": "4B2F9A0C1D3E5F7A",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "from": "+584241112233",
    "video": false,
    "groupId": null,
    "endReason": "rejected",
    "startedAt": null,
    "endedAt": "2026-09-24T14:11:30.000Z"
  }
}

Channels

›channel.message_receivedA new post in a channel the account follows. Forwarded as is, not stored.
{
  "object": {
    "object": "channel_message",
    "id": "118",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "channelId": "120363198765432109@newsletter",
    "type": "text",
    "text": "Doors open at 8.",
    "viewCount": null,
    "reactions": {},
    "sentAt": "2026-09-24T14:07:12.000Z"
  }
}
›channel.message_updatedNew view and reaction counts for a post in a followed channel. Not stored.
{
  "object": {
    "object": "channel_message",
    "id": "118",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "channelId": "120363198765432109@newsletter",
    "type": "text",
    "text": null,
    "viewCount": 412,
    "reactions": {
      "👍": 31
    },
    "sentAt": "2026-09-24T14:07:12.000Z"
  }
}
›channel.updatedThe account followed, unfollowed, muted or unmuted a channel. name is set on followed, role on unfollowed.
{
  "object": {
    "object": "channel_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "channelId": "120363198765432109@newsletter",
    "change": "muted",
    "name": null,
    "role": null
  }
}

History

›history.syncedOne push of the history WhatsApp sends after linking was stored, as messages with source history. One event per push, never one per message. Only for accounts with historySync recent: history import is off by default.
{
  "object": {
    "object": "history_sync",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chunk": 1,
    "syncType": "recent",
    "progress": 42,
    "part": 1,
    "conversationCount": 18,
    "messageCount": 500,
    "duplicateCount": 0
  }
}

Projects

›project.createdA project was created.
{
  "object": {
    "object": "project",
    "id": "m17c4e9t2n5a8x0d3f6h9j2k5l8p1q4r",
    "name": "Acme Dental",
    "externalId": "cus_4417",
    "metadata": {},
    "status": "active",
    "maxAccounts": 3,
    "accountCount": 0,
    "createdAt": "2026-09-24T14:00:00.000Z",
    "updatedAt": "2026-09-24T14:00:00.000Z"
  }
}
›project.updatedA project changed: name, external id, metadata, limit, or status (suspended or resumed).
{
  "object": {
    "object": "project",
    "id": "m17c4e9t2n5a8x0d3f6h9j2k5l8p1q4r",
    "name": "Acme Dental",
    "externalId": "cus_4417",
    "metadata": {},
    "status": "suspended",
    "maxAccounts": 3,
    "accountCount": 2,
    "createdAt": "2026-09-24T14:00:00.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›project.deletedThe background delete of a project finished: its accounts are logged out and deleted, its keys revoked and its webhook endpoints removed.
{
  "object": {
    "object": "project",
    "id": "m17c4e9t2n5a8x0d3f6h9j2k5l8p1q4r",
    "name": "Acme Dental",
    "externalId": "cus_4417",
    "metadata": {},
    "status": "active",
    "maxAccounts": 3,
    "accountCount": 0,
    "createdAt": "2026-09-24T14:00:00.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
›invitation.status_changedAn invitation changed status: the invitee started linking (in_progress), the number was linked (completed), linking stopped (failed, with failureReason), or you cancelled or resent it. previousAttributes.status is the status before. expired has no event: it is computed on read.
{
  "object": {
    "object": "invitation",
    "id": "n28d5f0u3o6b9y1e4g7i0k3m6o9r2s5t",
    "projectId": "m17c4e9t2n5a8x0d3f6h9j2k5l8p1q4r",
    "status": "completed",
    "url": null,
    "inviteeName": "Maria Perez",
    "inviteeEmail": "maria@example.com",
    "inviteePhone": null,
    "suggestedCountry": "MX",
    "proxyLocation": {
      "country": "MX",
      "city": "mexicocity",
      "strictCity": false
    },
    "methods": [
      "qr_code",
      "pairing_code"
    ],
    "accountName": "Front desk",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "failureReason": null,
    "returnUrl": "https://app.example.com/whatsapp/done",
    "metadata": {
      "store": "cdmx-2"
    },
    "emailSentAt": "2026-09-24T14:00:00.000Z",
    "expiresAt": "2026-10-01T14:00:00.000Z",
    "viewedAt": "2026-09-24T14:01:02.000Z",
    "startedAt": "2026-09-24T14:01:30.000Z",
    "completedAt": "2026-09-24T14:03:10.000Z",
    "createdAt": "2026-09-24T14:00:00.000Z",
    "updatedAt": "2026-09-24T14:03:10.000Z"
  },
  "previousAttributes": {
    "status": "in_progress"
  }
}

Webhooks

›webhook.testSent only when you press Send test event on an endpoint in the dashboard (Logs, Webhooks), to that endpoint, whether or not it subscribes to it. data.object is the endpoint. It is delivered once, with no retries, and signed like every other event.
{
  "object": {
    "object": "webhook_endpoint",
    "id": "n97p31n8b5c7z2y4u6ebt5gr1ew3qa8s",
    "projectId": null,
    "url": "https://example.com/webhooks/wuapi",
    "events": [
      "message.received"
    ],
    "active": true,
    "createdAt": "2026-09-24T14:00:00.000Z",
    "updatedAt": "2026-09-24T14:00:00.000Z"
  }
}

Projects

Three levels. Your organization pays: it holds the subscription, the branding and the organization keys. A project is one of your customers, or an environment: it has its own accounts, API keys, webhook endpoints, limits and usage, isolated from every other project. An account is a linked WhatsApp number with a name, like Sales or Support. You rebill your customers from the per-project usage we report.

In the dashboard, the switcher at the top of the sidebar picks a project and an account inside it. Every screen then shows that scope: accounts, messages, keys, webhooks and usage. Billing and branding stay with the organization.

curl -X POST https://api.wuapi.dev/v1/projects \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Dental","externalId":"cus_4417","maxAccounts":3}'

Who can reach what

credentialWuapi-Project headerscope
organization keyabsentThe whole organization: every project and unassigned resources.
organization keya project id or ext:<externalId>That project only. 404 project_not_found when it is not in the organization.
project keyabsent, or its own projectIts project only.
project keyany other project403 forbidden.
  1. A resource outside the scope answers 404 not_found, never 403, so one project cannot confirm another project's IDs exist.
  2. Accounts, messages, webhook endpoints and invitations created in a project scope belong to that project for good. With an organization key, POST /v1/accounts, POST /v1/webhook-endpoints and POST /v1/invitations take an optional projectId.
  3. Lists are filtered by scope. With an organization key they accept ?projectId= with a project id, ext:<externalId>, or none for unassigned resources only.
  4. Project keys cannot manage projects, branding, usage or organization keys: 403 forbidden. The header does not change what those organization routes return.
  5. Project keys start with wu_live_ like any key, and each has its own 600 requests per minute.

Endpoints

requesteffect
POST /v1/projects{name, externalId?, metadata?, maxAccounts?}. Returns 201 with the project. 409 already_exists on a duplicate externalId.
GET /v1/projectsPaginated. ?externalId= exact match, ?status= active or suspended.
GET /v1/projects/{projectId}Also takes ext:<externalId> as the id, here and below.
PATCH /v1/projects/{projectId}{name?, externalId?, metadata?, maxAccounts?, status?}. null clears maxAccounts and externalId.
DELETE /v1/projects/{projectId}Returns 204. From then on the project is 404 and its keys answer 401. In the background every account is logged out and deleted, then its webhook endpoints removed, and project.deleted fires.
POST /v1/projects/{projectId}/api-keys{name}. Returns 201 with the api_key; its key is shown only here.
GET /v1/projects/{projectId}/api-keysThe project's keys, without key.
DELETE /v1/projects/{projectId}/api-keys/{apiKeyId}Revoke. Returns 204.
GET /v1/projects/{projectId}/usageA project_usage. ?month=YYYY-MM, default the current month.

Suspension and limits

PATCH a project to status: suspended when a customer stops paying. Its numbers stay linked and every read keeps working, but every send and every write answers 403 project_suspended, whichever key makes it. Inbound keeps being stored and delivered, so nothing is lost while you sort it out. Set status: active to resume.

limitvalue
Accounts per projectmaxAccounts, set by you. One more answers 403 project_limit_reached.
Webhook endpoints5 for the organization, plus 5 per project.
Active API keys50 per organization, 20 per project.
Idempotency keysReplay only within the caller's scope.

Usage for rebilling

curl https://api.wuapi.dev/v1/usage/by-project?month=2026-09 \
  -H "Authorization: Bearer wu_live_..."

accountCount and billableAccountCount are current counts; month selects the month of proxyBytes, sentMessageCount and receivedMessageCount. sentMessageCount counts API sends WhatsApp accepted plus messages sent from the phone; receivedMessageCount counts contacts' messages. History imports count for neither. GET /v1/usage is the organization's bill for the current month in cents. We bill the organization, counting all its connected accounts together through graduated bands, each at its own price: $6 for your first number, $4.50 for every one after, and $3.50 from the 51st. Proxy: proxyBytes is every byte used this month, includedProxyBytes the pool (0.5 GB per account, at the most accounts billable this month), billableProxyBytes what is past it, and proxyFeeCents bills only that, at $0.99 per GB. Traffic used on the Free plan (or during a legacy free trial) is never billable, including in the month you upgrade. We never bill a project.

Invitations

An invitation is a page you send to someone else, your customer, a store manager or a sales rep, so they link their own WhatsApp into one of your projects. They need no account, dashboard or API key. Create one, send them url (or let us email it), and the number arrives as an account in the project.

curl -X POST https://api.wuapi.dev/v1/invitations \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"projectId":"ext:cus_4417","inviteeName":"Maria Perez","inviteeEmail":"maria@example.com","accountName":"Front desk","suggestedCountry":"MX","returnUrl":"https://app.example.com/whatsapp/done","metadata":{"store":"cdmx-2"}}'
fieldnotes
projectIdOrganization keys only; a project key invites into its own project. Without it, the account is unassigned.
accountNameName of the account created on completion. Default: inviteeName, else the WhatsApp profile name.
inviteeName, inviteeEmailShown on the page as "Setting up for ...". With an email, we send the invitation by email when email is enabled; emailSentAt is set when it was queued.
inviteePhoneE.164. Preselects the country and prefills the pairing-code form.
suggestedCountryISO 3166-1 alpha-2. Preselects the country question. Derived from inviteePhone when absent.
proxyLocation{country, city} from GET /v1/proxy-locations. Sets where the account exits and skips the question on the page. Without it the invitee picks the country and city, and proxyLocation stays null until they do.
methods["qr_code", "pairing_code"] by default: which linking methods the page offers.
historySyncnone by default. recent makes the linked account import the recent chats the phone sends once, right after linking.
returnUrlHTTPS. After linking, the page shows a link back there with ?invitation_id=...&account_id=... added. It does not redirect on its own.
expiresInDays1 to 30. Default 7.
metadataUp to 20 string pairs, copied onto the account when the number is linked.

Creating an invitation checks what creating an account checks: 402 upgrade_required on the Free plan once its 1 account is taken, 403 project_suspended, 403 project_limit_reached and, on a legacy free trial, 403 trial_account_limit. They are checked again when the invitee starts, because that is when the account is created. url is returned only by create and resend: the token in it is stored hashed.

What the invitee sees

  1. Welcome: your name, logo and color from branding, who it is for, what they need (the phone with WhatsApp, about two minutes) and what happens: the number becomes a linked device, their chats stay on the phone, and they can unlink it from WhatsApp, Linked devices.
  2. Location: the country and city the number is from, the country preselected from suggestedCountry. It sets the account's proxy exit before its session starts. Skipped when you set proxyLocation, or when they come back to an invitation that already has an account.
  3. Link: a live QR code that refreshes on its own, or their phone number and an 8-character pairing code to type in WhatsApp, Linked devices, Link with phone number instead. A new code can be requested every 15 seconds.
  4. Done: the linked number and its WhatsApp name, and a link back to returnUrl when you set one.
  5. A cancelled, expired or already used invitation shows a neutral page that names the state and tells them to ask you for a new one.

The page lives at wuapi.dev/invite/.... It is noindex, drops the referrer, has no analytics, and works on a 375 px screen. It shows nothing about your other projects, accounts or invitations.

Statuses

statusmeaning
pendingCreated, or resent. viewedAt is set once the page is opened.
in_progressThe invitee started linking; the account exists (GET /v1/accounts lists it, not ready yet).
completedThe account reached ready. accountId is set, and account.connected fires too.
failedLinking stopped. failureReason is abandoned (in progress for 1 hour without linking), qr_timeout, logged_out, temporary_ban, connect_failed or account_deleted. The link keeps working: a retry moves it back to in_progress with the same account.
cancelledYou cancelled it. The link stops working.
expiredPending or failed past expiresAt. Computed when read, never stored.

Endpoints

requesteffect
POST /v1/invitationsCreate. Returns 201 with the invitation, url included.
GET /v1/invitationsPaginated, newest first, in the key's scope. ?status= any status above, ?projectId= with an organization key.
GET /v1/invitations/{invitationId}url is always null here.
POST /v1/invitations/{invitationId}/cancelReturns the invitation. 409 already_completed when completed. Cancelling twice is a no-op.
POST /v1/invitations/{invitationId}/resendNew token: the old URL stops working. Back to pending with a fresh expiry, and the email goes out again. Returns the invitation with the new url. 409 already_completed when completed.

Every real status change fires invitation.status_changed, with the invitation in data.object and the status before in data.previousAttributes.status, to organization endpoints and to the project's endpoints. expired has no event, because nothing changes when time passes.

Email

With inviteeEmail set, the invitee gets one plain email in your name: "{displayName} invited you to connect WhatsApp", a button and the expiry, with your logo and color. It carries no wuapi mark while hideWuapiBranding is on and the White label add-on is active. When email is not enabled, the invitation is still created and emailSentAt is null: send the url yourself.

Branding

curl -X PATCH https://api.wuapi.dev/v1/branding \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"displayName":"Acme Cloud","logoUrl":"https://acme.example/logo.png","accentColor":"#2563EB"}'

GET and PATCH /v1/branding, organization keys only. Until it is set, GET returns the object with displayName null. displayName (up to 60 characters) is required the first time. logoUrl and supportUrl are HTTPS; accentColor is #RRGGBB; all three take null to clear. A logo uploaded in the dashboard (PNG, JPEG, SVG or WebP, up to 512 KB) wins over logoUrl, and setting logoUrl replaces it. The page title and favicon never carry our brand. Without an accent color the page uses ours, or a neutral one when our branding is hidden.

White label

curl -X PATCH https://api.wuapi.dev/v1/branding \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"hideWuapiBranding":true}'

hideWuapiBranding: true removes the Powered by wuapi footer from the page and the Sent by wuapi line from the email. It needs the White label add-on, $100/month on your usage subscription, added from Billing or Branding in the dashboard. Without it, true answers 402 addon_required with details: {addon: "white_label", priceCents: 10000}, and GET returns false, because the footer shows. Remove the add-on and the footer is back right away; add it again and your saved choice applies.

Errors

Errors share one shape: {code, message, details?}. The same situation answers the same code on every endpoint. Branch on code, not on message.

{
  "code": "invalid_request",
  "message": "`text` is required.",
  "details": {
    "field": "text"
  }
}
statuscodewhen
400invalid_requestThe body, query, path or cursor failed validation. details.field names the field when there is one.
400unsupported_proxy_locationproxyLocation names a country or city that is not in GET /v1/proxy-locations.
400not_supportedWhatsApp cannot do that for this account, for example labels on a consumer-app number, or the bot directory and link resolution where WhatsApp does not offer them.
400not_on_whatsappA live operation named a number that has no WhatsApp.
400username_not_supportedA WhatsApp username (@handle) this account cannot use: to names one it has no chat with, or a field that takes contact ids got one. WhatsApp does not let a linked device look up usernames.
401unauthorizedThe API key is missing, malformed or revoked, or belongs to a deleted project.
402subscription_requiredA new account or invitation while the subscription is past due, or a billing add-on without a subscription. details.billingUrl links to Billing.
402upgrade_requiredThe Free plan includes 1 connected account: a second account, invitation or reconnect that would take another slot is refused, and so are sends from a Free organization with more than 1 billable account (after a paid subscription ended) until the extra ones are removed. Upgrade in Billing. details has maxAccounts and upgradeUrl, plus accountId when the account holding the slot is still linking.
402free_limit_reachedThe Free plan's monthly limit is reached (2,000 messages sent and received, or 0.5 GB of proxy traffic). Sends, new accounts and reconnects are refused and the account is paused until the month ends (UTC) or the organization upgrades; messages already queued wait up to 24 hours, then fail with this code. details has limit (messages or proxy), month, resetsAt and upgradeUrl.
402addon_requiredhideWuapiBranding: true needs the White label add-on. details has addon and priceCents.
402trial_proxy_limit_reachedLegacy free trials only (new organizations start on the Free plan): the trial's 0.5 GB of proxy traffic is used up. Sends are refused until the trial ends. details.upgradeUrl links to Billing; details.limitMb has the cap.
402payment_requiredProxy traffic is paused because an invoice is unpaid. Sends, stories, new accounts and reconnects are refused until it is paid; the accounts reconnect on their own then. details.billingUrl links to Billing.
402proxy_spend_cap_reachedProxy traffic is paused because this month's proxy overage reached the organization's monthly cap. The same calls are refused until the cap is raised in Billing or the month ends. details.capCents has the cap, details.billingUrl links to Billing.
403forbiddenA project key asked for another project, or for an organization-only route.
403project_suspendedA send or write to a suspended project's resources. Reads keep working.
403project_limit_reachedThe project is at maxAccounts. details.maxAccounts has the limit.
403trial_account_limitLegacy free trials only: the trial includes one connected account until it converts. details.upgradeUrl links to Billing.
403whatsapp_forbiddenWhatsApp refused: the account is not a member, not an admin, or not allowed.
404not_foundThe route, or the resource, does not exist or is outside the key's scope.
404project_not_foundThe Wuapi-Project header or projectId names no project in this organization.
404group_not_found, channel_not_found, invite_not_found, picture_not_found, business_profile_not_found, link_not_found, sticker_pack_not_found, order_not_foundWhat was asked for does not exist on WhatsApp, or is hidden from this account.
404whatsapp_not_foundWhatsApp does not know the target, in any other case.
409account_not_readyThe account is not ready, so it cannot send or run live operations. details.status has its status when known.
409already_linkedA pairing code for an account that is already linked.
409already_completedCancelling or resending an invitation that is already completed.
409already_existsA project with that externalId exists.
409idempotency_conflictThe Idempotency-Key belongs to a request that is still running, or was used with a different request.
409message_sendingDELETE or PATCH of a queued message while it is being handed to WhatsApp. Retry in a few seconds: it is then sent (and the change goes to WhatsApp) or failed.
429rate_limitedToo many requests, WhatsApp is rate limiting the account, or its proxy location changed less than 10 minutes ago. Wait for the Retry-After header.
500internal_errorSomething failed on our side. Safe to retry a send with the same Idempotency-Key.
502whatsapp_errorWhatsApp failed the operation. Retrying may work.
503engine_unavailableThe connection to WhatsApp is briefly unreachable. Retry shortly.

A send that was accepted and then fails is not an HTTP error: the message moves to failed with error: {code, message} (not_on_whatsapp, rate_limited, account_offline, payment_required, proxy_spend_cap_reached, cancelled or send_failed), and message.failed fires.

Rate limits and pacing

Each API key may make 600 requests per minute. Every response says where you stand with RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window resets). Past the limit, requests return 429 with code rate_limited and a Retry-After header in seconds.

Separately, each number has a send queue. Messages leave it one at a time, and by default as fast as WhatsApp accepts them: the anti-ban protections (a per-minute cap, a first-contact cap and a typing indicator) are off until you turn them on for the account. See Sending safely for when to turn them on and the values we recommend. With a cap on, sends over the pace stay queued and go out as slots free up, for up to an hour; after that they fail with rate_limited. Queued messages are not guaranteed to leave in the order you sent them.

Edits, poll votes and the auto-reject reply to a call go through the same queue and pacing. Stories and channel posts count against the per-minute cap only, since they have no single recipient.

Set it per account

Every account returns its effective pacing, with custom: false while it runs on the defaults (every protection off). Change it with PATCH /v1/accounts/{accountId} and a pacing object. Fields you send merge over what is stored; 0 turns a cap off, null on a field resets it and pacing: null resets everything. The change applies from the next send, without reconnecting.

fielddefaultrecommendedallowed
`messagesPerMinute`0 (off)120 to 30, 0 is off
`firstContactPerMinute`0 (off)50 to messagesPerMinute (to 30 while that is off), 0 is off
`typing.enabled`falsetrueboolean
`typing.minMs`8008000 to 10000
`typing.maxMs`60006000typing.minMs to 20000
`typing.charsPerSecond`25255 to 100
`queueTimeoutMinutes`60601 to 1440
curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"pacing":{"messagesPerMinute":20,"typing":{"enabled":true,"maxMs":4000},"queueTimeoutMinutes":120}}'

A value out of range answers 400 invalid_request with details: {field, min, max}, for example {"field": "pacing.messagesPerMinute", "min": 0, "max": 30}. A first-contact cap can't be higher than the per-minute cap while that one is on. The account page in the dashboard edits the same values.

Sending safely (anti-ban recommendations)

WhatsApp decides which numbers it restricts, and bursts of messages to people who don't know the number are one of the clearest signals it looks at. wuapi can pace each number to look like a person: a per-minute cap, a tighter cap for people who never wrote to it, and a typing indicator before each message. These protections are off by default, so a number sends as fast as WhatsApp accepts, one message at a time. You turn them on per account.

They are off by default because many numbers don't need them. An app that answers people who just wrote to it, or sends a login code the person asked for, would only be slowed down: a code that arrives a minute late is a failed login. A number that writes first to many people is a different case, and there we recommend turning them on.

What each protection does

protectionwhat it doesrecommended
Per-minute cap, `messagesPerMinute`At most this many messages a minute from the number, with a random 1 to 3 second gap between them, so sends never go out as an even burst. Everything the number sends counts: chats, groups, edits, poll votes, stories and channel posts.12
First-contact cap, `firstContactPerMinute`At most this many messages a minute to people who never wrote to the number. A contact who replied, groups and your own number don't count against it.5
Typing indicator, `typing`Shows "typing..." in the chat before each message, for the length of the text at charsPerSecond, between minMs and maxMs.enabled: true, 800 to 6000 ms at 25 characters per second

Two things are always on and aren't protections you configure. Before a first message to someone, wuapi asks WhatsApp whether the number is on it, and a message to a number that isn't ends failed with not_on_whatsapp. And each number sends one message at a time, in order.

When to turn them on

  1. Bulk sends: a campaign, an announcement, a list of any size.
  2. Cold outreach: writing first to people who never wrote to the number.
  3. Marketing and promotions, even to customers who opted in.
  4. A new number, for its first weeks. Start slower than the recommended values and raise them as replies come in.
  5. A number that was restricted before, from the day it comes back.

When you can leave them off

  1. Transactional replies to people who wrote first: support, an AI agent answering a chat, order questions.
  2. One-time codes and alerts the person is waiting for, where a delay breaks the flow.
  3. Messages to your own number and to your team while you build and test.

If one number does both, turn the protections on: replies to people who wrote first skip the first-contact cap anyway, and only pay the per-minute cap and the typing time. Or keep the two kinds of traffic on different numbers.

Turn them on

With the API, send the recommended values in pacing. In the dashboard, open the account and choose Use recommended settings under Pacing, or turn each protection on by itself.

curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"pacing":{"messagesPerMinute":12,"firstContactPerMinute":5,"typing":{"enabled":true,"minMs":800,"maxMs":6000,"charsPerSecond":25}}}'

Each protection can be set alone. 0 turns a cap off again, and pacing: null puts everything back to the defaults.

curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"pacing":{"typing":{"enabled":true}}}'

No setting makes cold bulk messaging safe. The protections remove the most obvious signal, sending like a machine. WhatsApp also reads what you send and how people react to it.

Good practice

  1. Only message people who expect it: they gave you their number and know why you're writing.
  2. Warm up a new number. A few conversations a day at first, then more as people reply.
  3. Don't send the same text to many people. Use their name, their order, their appointment.
  4. Respect opt-outs. Make stopping easy, and stop the first time someone asks.
  5. Keep the first message to a new contact plain: no links or attachments until they reply.
  6. Set a name, a photo and an about line on the number before it sends anything.
  7. Watch replies and blocks. When people stop answering a message, stop sending it.

More habits, a warm-up plan and what to do when WhatsApp restricts a number: wuapi.dev/guides/avoid-restrictions

What is not supported

The gaps we know of, each with its reason.

not supportedwhy
Templates, buttons, list messagesFeatures of the official WhatsApp Business Platform. Use the official platform if you need them.
Delete for meWhatsApp's libraries can read that change but not write it. Delete for everyone works.
Votes on polls the account never sawVotes are encrypted against the poll. For a poll sent before the number was linked here, poll.voted arrives with an empty options list.
Calendar event RSVPs, and reactions or comments encrypted with a message secretNot decrypted, so not delivered.
Calendar event call linkscallType marks an event as a call; the link itself is created by WhatsApp on the phone.
Contacts' storiesYou can post a story; the ones your contacts post are not received.
History on demandHistory arrives once, after linking, in the amount WhatsApp decides.
Chat changes from the initial syncOnly live changes arrive as chat.updated.
Answering or placing callsCalls can be seen and rejected, not taken.

wuapi is not the WhatsApp Business Platform and is not affiliated with, endorsed or sponsored by WhatsApp. We cannot promise a number is never restricted: WhatsApp restricts numbers for what they send and how recipients react.

TypeScript SDK

The @wuapidev/sdk package on npm wraps the REST API with types for every request, response and event. It has no runtime dependencies and runs on Node 18+, Bun, Deno and edge runtimes.

npm install @wuapidev/sdk
import { Wuapi } from "@wuapidev/sdk"

const wuapi = new Wuapi({ apiKey: process.env.WUAPI_API_KEY })

// 1. Pick where the number exits, then create the account.
//    Requires an active subscription (402 otherwise).
const [location] = await wuapi.proxyLocations.list({ country: "CL" }).toArray(1)
const account = await wuapi.accounts.create({
  name: "Support line",
  proxyLocation: { country: location.country, city: location.city }, // e.g. CL / santiago
})

// 2. Wait for the QR code and show it to the phone owner.
//    On the phone: WhatsApp > Linked devices > Link a device.
const withQr = await wuapi.accounts.waitForQrCode(account.id)
console.log("Scan this QR code:", withQr.qrCodeUrl) // PNG data URL, e.g. <img src={qrCodeUrl} />

// 3. Wait until the phone finishes linking. The QR code rotates while it
//    waits; onQrCode is called with each new one.
const ready = await wuapi.accounts.waitUntilReady(account.id, {
  onQrCode: (qrCodeUrl) => console.log("New QR code:", qrCodeUrl),
})
console.log("Linked", ready.phone)

// 4. Send a message.
const message = await wuapi.messages.send({
  accountId: ready.id,
  to: "+584241112233",
  text: "Your order has shipped.",
})
console.log(message.id, message.status) // "queued"

apiKey falls back to the WUAPI_API_KEY environment variable. Methods return the resource itself: accounts.get(id) returns an account and messages.send() returns the message. Every method takes an optional last argument { idempotencyKey?, signal? }; idempotencyKey is sent as the Idempotency-Key header, and the SDK generates one for every POST when you do not pass it, so retries never send twice.

Lists

List methods return a paginator, including the lists read live from WhatsApp such as groups.list and channels.listMessages. Iterate it to walk every item across pages, or call .page() for one page. Batch results, such as contacts.check and groups.addParticipants, return an array.

for await (const message of wuapi.messages.list({ accountId, direction: "inbound" })) {
  console.log(message.from, message.text)
}

const { items, nextCursor } = await wuapi.messages.list({ limit: 20 }).page()
const next = await wuapi.messages.list({ limit: 20 }).page(nextCursor ?? undefined)

Errors and retries

Every non-2xx response throws a WuapiError with status, code, message, details and requestId. The client retries up to maxRetries times (default 2): 429 after Retry-After, and 5xx, network errors and timeouts for requests that are safe to repeat, which includes every POST carrying an idempotency key.

import { WuapiError } from "@wuapidev/sdk"

try {
  await wuapi.messages.send({ accountId, to: "+584241112233", text: "hi" })
} catch (err) {
  if (err instanceof WuapiError && err.code === "account_not_ready") {
    await wuapi.accounts.reconnect(accountId)
  }
}

Reference

resourcemethods
proxyLocationslist
accountslist, create, get, update, delete, reconnect, logout, createPairingCode, setPresence, setDefaultDisappearingTimer, waitForQrCode, waitForPairingCode, waitUntilReady
messagessend, list, get, edit, delete, react, vote, star, unstar, addLabel, removeLabel
chatssendPresence, sendReadReceipts, markRead, markUnread, archive, unarchive, pin, unpin, mute, unmute, delete, setDisappearingTimer, addLabel, removeLabel
storiescreate
contactscheck, lookup, getPicture, getBusinessProfile, subscribePresence, block, unblock, listBlocked, getLink, resetLink, resolveLink
botslist
profileupdate, setPicture, deletePicture
privacyget, update, getStoryPrivacy
labelsupsert, delete
callsreject
stickerPacks, ordersget
groupslist, create, get, update, leave, addParticipants, removeParticipants, promoteParticipants, demoteParticipants, getInviteLink, resetInviteLink, join, getInvite, setPicture, deletePicture, listJoinRequests, approveJoinRequests, rejectJoinRequests, listSubgroups, linkSubgroup, unlinkSubgroup, listCommunityParticipants
channelslist, create, get, getInvite, follow, unfollow, mute, unmute, listMessages, react, markViewed
webhookEndpointslist, create, get, update, delete, rotateSecret
projectslist, create, get, update, delete, getUsage, apiKeys.list, apiKeys.create, apiKeys.revoke
invitationscreate, list, get, cancel, resend
brandingget, update
usageget, byProject
clientme(), withProject(project)

Account-level resources take the accountId first. The curl examples in these docs work with any HTTP client and the same key.

verifyWebhook(rawBody, header, secret) resolves to the typed event, discriminated on type with data.object typed per event, or throws WebhookVerificationError when the signature is missing, wrong or more than 300 seconds old. Pass a fourth argument to change the tolerance.

OpenAPI

The complete API is described in OpenAPI 3.1 at /openapi.json: every operation with a stable operationId, examples, and every error code it can answer. Use it to generate a client in another language or import it into your HTTP tool.

https://wuapi.dev/openapi.json