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.
- 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.
- 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. - 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. - 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
- Fields are camelCase. Enum values are lowercase snake_case:
qr_ready,pairing_code. - A resource's own id is
id; a reference to another is<resource>Id:accountId,projectId,contactId,replyToMessageId. - Timestamps are ISO 8601 strings in UTC and end in
At:createdAt,sentAt,expiresAt. - Durations and sizes carry the unit:
durationSeconds,queueTimeoutMinutes,proxyBytes. Money is integer cents in…Cents, next tocurrency. - Counts end in
Count, URLs inUrl, and a name WhatsApp reported isprofileName.
WhatsApp identities
| what | format | example |
|---|---|---|
| a contact with a known number | E.164 with + | +584241112233 |
| a contact whose number WhatsApp hides | lid: and digits | lid:201843727138927 |
| a group | WhatsApp group id | 120363041234567890@g.us |
| a channel | WhatsApp channel id | 120363198765432109@newsletter |
| the account's stories | literal | stories |
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
| header | direction | what |
|---|---|---|
| Authorization | request | Bearer wu_live_... on every request. |
| Wuapi-Project | request | Scope an organization key to one project: its id or ext:<externalId>. See Projects. |
| Idempotency-Key | request | On 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-id | response | On 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-Reset | response | Your key's budget for the current minute: the limit, what is left, and the seconds until it resets. |
| Retry-After | response | On every 429, in seconds. |
| Idempotent-Replayed | response | true when the response is a replay of an earlier request with the same Idempotency-Key. |
| Original-Request | response | On 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/wuapiThe 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.
| skill | covers |
|---|---|
| wuapi-rules | Always loaded. What wuapi is and is not, auth, projects, errors, pagination, idempotency, pacing, webhook signing. |
| link-account | Link a number by QR or pairing code, wait for ready, pick the exit country, reconnect or log out. |
| send-message | Every send type and option, replies, mentions, polls, events, edits, reactions, Status and channel posts. |
| receive-webhooks | Endpoints, signature verification, the full event catalog, retries and history sync. |
| groups-and-channels | Groups, communities, join requests, invite links and channels. |
| chats-contacts-profile | Chat actions, read receipts, labels, contacts, blocklist, profile, privacy and calls. |
| projects-and-invitations | A 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) | |
|---|---|---|
| runs | on your machine: npx -y @wuapidev/mcp | at https://wuapi.dev/api/mcp |
| key | WUAPI_API_KEY environment variable | Authorization: Bearer wu_live_... header |
| needs | Node 20 or later | a 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 variable | hosted header | does |
|---|---|---|
| WUAPI_API_KEY | Authorization: Bearer ... | Required. Your API key. |
| WUAPI_PROJECT | Wuapi-Project | Act inside one project: its id or ext:<externalId>. |
| WUAPI_MCP_READ_ONLY | Wuapi-Read-Only | true registers only the tools that read. The --read-only flag does the same. |
| WUAPI_BASE_URL | API base URL. Default https://api.wuapi.dev. Must be https, or http on localhost. |
Tools
| area | tools |
|---|---|
| Context | get_current_key |
| Accounts | list_accounts, get_account, get_account_qr_code, create_account, request_pairing_code, reconnect_account, list_proxy_locations |
| Messages | send_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 |
| Chats | mark_chat_read, send_read_receipts, archive_chat, pin_chat, mute_chat |
| Contacts | check_numbers, lookup_contacts |
| Groups | list_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 |
| Stories | post_story |
| Webhooks | list_webhooks, create_webhook, update_webhook, delete_webhook |
| Projects | list_projects, get_project, create_project |
| Invitations | create_invitation, list_invitations, get_invitation, cancel_invitation |
| Usage | get_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 ask | the 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
- 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/mewhen a client connects, then passes it to the API on each call. - Tool results carry no secrets. A webhook endpoint's signing secret is returned by the API only when the endpoint is created;
create_webhookdrops it and tells you to reveal it in the dashboard. No tool creates API keys. - Narrow what the model can do: a project key reaches one project, and read-only mode removes every tool that writes.
- Media URLs are downloaded by the API, which refuses private and internal addresses; the MCP server fetches nothing on a tool's behalf.
- 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.
- 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
| status | meaning |
|---|---|
| initializing | The session is starting. |
| qr_ready | Waiting 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. |
| authenticating | The phone accepted the link and is completing it. |
| ready | Linked. You can send. The account is billable from the first time it reaches ready. |
| disconnected | The connection dropped. disconnectReason says why. Most reasons reconnect on their own; reconnecting is true while one does. |
| failed | The 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
| request | effect |
|---|---|
| GET /v1/accounts | List 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}/reconnect | Restart the session; returns 202 with the account. Produces a new QR code when the link was lost. |
| POST /v1/accounts/{accountId}/logout | Unlink 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.
| field | notes |
|---|---|
| accountId | Required. The account to send from. |
| to | Required. 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. |
| type | Required, one of the types below. A body without type is sent as text. |
| replyToMessageId | A wuapi message ID in the same chat, to quote it. Works with every type. |
| mentions | Contacts to mention, up to 256. |
| mentionAll | true mentions every participant. Groups only. |
| forwarded | true marks the message as forwarded. |
| disappearingSeconds | 0, 86400, 604800 or 7776000. Match the chat's timer. |
| metadata | Your own string key-value pairs, up to 50 keys, returned on the message and in webhooks. |
| type | requires | also takes |
|---|---|---|
| text | text: 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, video | media: {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, voice | media, as above. voice sends the audio as a voice note. | text, viewOnce. |
| document, sticker | media, as above. | text as the caption. |
| location | location: {latitude, longitude, name?, address?}. | |
| contact | contact: {name, phone}. | |
| contacts | contacts: 2 to 20 {name, phone} cards in one message. | |
| poll | poll: {name, options, selectableCount?}. 2 to 12 unique options, each up to 100 characters. | poll.selectableCount: 0 (default) lets voters pick any number. |
| calendar_event | calendarEvent: {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"
}type: "voice"sends an audio as a voice note. We do not transcode: send ogg/opus.calendarEvent.callType(audioorvideo) marks the event as a scheduled WhatsApp call. The call link itself is created by WhatsApp on the phone, sojoinUrlstaysnullon events you send.- A poll's votes arrive as
poll.votedwebhooks, and the stored poll keeps the tally inpoll.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
| request | effect |
|---|---|
| GET /v1/messages | List 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}/star | Star 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, /unarchive | Archive or unarchive. |
| POST /chats/{chatId}/pin, /unpin | Pin or unpin. |
| POST /chats/{chatId}/mute, /unmute | mute takes {durationSeconds?}. Without a duration, or 0, it mutes until unmuted. |
| POST /chats/{chatId}/mark-read, /mark-unread | Mark 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}/picture | A 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-profile | Address, email, categories and hours of a business account. 404 business_profile_not_found otherwise. |
| POST /contacts/{contactId}/subscribe-presence | Receive contact.presence_updated when that contact goes online or offline. Returns 204. |
| POST /contacts/{contactId}/block, /unblock | Block or unblock. Returns 204. |
| GET /blocklist | A 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-link | The 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/picture | Remove the picture. Returns 204. |
| GET /privacy | The account's privacy_settings. |
| PATCH /privacy | Any of the settings below, for example {readReceipts: "none"}. Returns the settings after the change. |
| GET /privacy/stories | Who 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"}'| setting | values |
|---|---|
| groupAdd | all, contacts, contact_blacklist, none |
| lastSeen | all, contacts, contact_blacklist, none |
| stories | all, contacts, contact_blacklist, none |
| profile | all, contacts, contact_blacklist, none |
| readReceipts | all, none |
| online | all, match_last_seen |
| callAdd | all, known |
| messages | all, 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 /groups | Groups 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-requests | Pending 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-link | A 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}/leave | Leave 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 /subgroups | The 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-participants | Everyone 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 /channels | Channels 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, /unfollow | Follow or unfollow. Returns 204. |
| POST /channels/{channelId}/mute, /unmute | Mute or unmute. Returns 204. |
| GET /channels/{channelId}/messages | Recent 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.
| request | effect |
|---|---|
| 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"}'- WhatsApp sends history once, right after linking. Changing
historySyncapplies to the next link: a number that is already linked gets no history, not even after a reconnect. - With
historySyncnonenothing is stored andhistory.syncednever fires. Contact names and live messages work the same either way. - Invitations take
historySynctoo; the account the invitee links gets it. It isnoneunless you set it. - History arrives in pushes of at most 500 messages. Each push fires one
history.syncedwith counts, never one webhook per message. - Historical messages carry no media:
typesays what it was andmediaisnull. - Messages already stored are skipped and counted in
duplicateCount. - History does not count toward
sentMessageCountorreceivedMessageCountin usage. - 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 /bots | WhatsApp'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
| credential | Wuapi-Project header | scope |
|---|---|---|
| organization key | absent | The whole organization: every project and unassigned resources. |
| organization key | a project id or ext:<externalId> | That project only. 404 project_not_found when it is not in the organization. |
| project key | absent, or its own project | Its project only. |
| project key | any other project | 403 forbidden. |
- A resource outside the scope answers
404 not_found, never403, so one project cannot confirm another project's IDs exist. - 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-endpointsandPOST /v1/invitationstake an optionalprojectId. - Lists are filtered by scope. With an organization key they accept
?projectId=with a project id,ext:<externalId>, ornonefor unassigned resources only. - Project keys cannot manage projects, branding, usage or organization keys:
403 forbidden. The header does not change what those organization routes return. - Project keys start with
wu_live_like any key, and each has its own 600 requests per minute.
Endpoints
| request | effect |
|---|---|
| POST /v1/projects | {name, externalId?, metadata?, maxAccounts?}. Returns 201 with the project. 409 already_exists on a duplicate externalId. |
| GET /v1/projects | Paginated. ?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-keys | The project's keys, without key. |
| DELETE /v1/projects/{projectId}/api-keys/{apiKeyId} | Revoke. Returns 204. |
| GET /v1/projects/{projectId}/usage | A 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.
| limit | value |
|---|---|
| Accounts per project | maxAccounts, set by you. One more answers 403 project_limit_reached. |
| Webhook endpoints | 5 for the organization, plus 5 per project. |
| Active API keys | 50 per organization, 20 per project. |
| Idempotency keys | Replay 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"}}'| field | notes |
|---|---|
| projectId | Organization keys only; a project key invites into its own project. Without it, the account is unassigned. |
| accountName | Name of the account created on completion. Default: inviteeName, else the WhatsApp profile name. |
| inviteeName, inviteeEmail | Shown 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. |
| inviteePhone | E.164. Preselects the country and prefills the pairing-code form. |
| suggestedCountry | ISO 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. |
| historySync | none by default. recent makes the linked account import the recent chats the phone sends once, right after linking. |
| returnUrl | HTTPS. After linking, the page shows a link back there with ?invitation_id=...&account_id=... added. It does not redirect on its own. |
| expiresInDays | 1 to 30. Default 7. |
| metadata | Up 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
- 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.
- 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 setproxyLocation, or when they come back to an invitation that already has an account. - 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.
- Done: the linked number and its WhatsApp name, and a link back to
returnUrlwhen you set one. - 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
| status | meaning |
|---|---|
| pending | Created, or resent. viewedAt is set once the page is opened. |
| in_progress | The invitee started linking; the account exists (GET /v1/accounts lists it, not ready yet). |
| completed | The account reached ready. accountId is set, and account.connected fires too. |
| failed | Linking 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. |
| cancelled | You cancelled it. The link stops working. |
| expired | Pending or failed past expiresAt. Computed when read, never stored. |
Endpoints
| request | effect |
|---|---|
| POST /v1/invitations | Create. Returns 201 with the invitation, url included. |
| GET /v1/invitations | Paginated, 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}/cancel | Returns the invitation. 409 already_completed when completed. Cancelling twice is a no-op. |
| POST /v1/invitations/{invitationId}/resend | New 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.
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"
}
}| status | code | when |
|---|---|---|
| 400 | invalid_request | The body, query, path or cursor failed validation. details.field names the field when there is one. |
| 400 | unsupported_proxy_location | proxyLocation names a country or city that is not in GET /v1/proxy-locations. |
| 400 | not_supported | WhatsApp 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. |
| 400 | not_on_whatsapp | A live operation named a number that has no WhatsApp. |
| 400 | username_not_supported | A 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. |
| 401 | unauthorized | The API key is missing, malformed or revoked, or belongs to a deleted project. |
| 402 | subscription_required | A new account or invitation while the subscription is past due, or a billing add-on without a subscription. details.billingUrl links to Billing. |
| 402 | upgrade_required | The 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. |
| 402 | free_limit_reached | The 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. |
| 402 | addon_required | hideWuapiBranding: true needs the White label add-on. details has addon and priceCents. |
| 402 | trial_proxy_limit_reached | Legacy 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. |
| 402 | payment_required | Proxy 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. |
| 402 | proxy_spend_cap_reached | Proxy 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. |
| 403 | forbidden | A project key asked for another project, or for an organization-only route. |
| 403 | project_suspended | A send or write to a suspended project's resources. Reads keep working. |
| 403 | project_limit_reached | The project is at maxAccounts. details.maxAccounts has the limit. |
| 403 | trial_account_limit | Legacy free trials only: the trial includes one connected account until it converts. details.upgradeUrl links to Billing. |
| 403 | whatsapp_forbidden | WhatsApp refused: the account is not a member, not an admin, or not allowed. |
| 404 | not_found | The route, or the resource, does not exist or is outside the key's scope. |
| 404 | project_not_found | The Wuapi-Project header or projectId names no project in this organization. |
| 404 | group_not_found, channel_not_found, invite_not_found, picture_not_found, business_profile_not_found, link_not_found, sticker_pack_not_found, order_not_found | What was asked for does not exist on WhatsApp, or is hidden from this account. |
| 404 | whatsapp_not_found | WhatsApp does not know the target, in any other case. |
| 409 | account_not_ready | The account is not ready, so it cannot send or run live operations. details.status has its status when known. |
| 409 | already_linked | A pairing code for an account that is already linked. |
| 409 | already_completed | Cancelling or resending an invitation that is already completed. |
| 409 | already_exists | A project with that externalId exists. |
| 409 | idempotency_conflict | The Idempotency-Key belongs to a request that is still running, or was used with a different request. |
| 409 | message_sending | DELETE 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. |
| 429 | rate_limited | Too many requests, WhatsApp is rate limiting the account, or its proxy location changed less than 10 minutes ago. Wait for the Retry-After header. |
| 500 | internal_error | Something failed on our side. Safe to retry a send with the same Idempotency-Key. |
| 502 | whatsapp_error | WhatsApp failed the operation. Retrying may work. |
| 503 | engine_unavailable | The 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.
| field | default | recommended | allowed |
|---|---|---|---|
| `messagesPerMinute` | 0 (off) | 12 | 0 to 30, 0 is off |
| `firstContactPerMinute` | 0 (off) | 5 | 0 to messagesPerMinute (to 30 while that is off), 0 is off |
| `typing.enabled` | false | true | boolean |
| `typing.minMs` | 800 | 800 | 0 to 10000 |
| `typing.maxMs` | 6000 | 6000 | typing.minMs to 20000 |
| `typing.charsPerSecond` | 25 | 25 | 5 to 100 |
| `queueTimeoutMinutes` | 60 | 60 | 1 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
| protection | what it does | recommended |
|---|---|---|
| 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
- Bulk sends: a campaign, an announcement, a list of any size.
- Cold outreach: writing first to people who never wrote to the number.
- Marketing and promotions, even to customers who opted in.
- A new number, for its first weeks. Start slower than the recommended values and raise them as replies come in.
- A number that was restricted before, from the day it comes back.
When you can leave them off
- Transactional replies to people who wrote first: support, an AI agent answering a chat, order questions.
- One-time codes and alerts the person is waiting for, where a delay breaks the flow.
- 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
- Only message people who expect it: they gave you their number and know why you're writing.
- Warm up a new number. A few conversations a day at first, then more as people reply.
- Don't send the same text to many people. Use their name, their order, their appointment.
- Respect opt-outs. Make stopping easy, and stop the first time someone asks.
- Keep the first message to a new contact plain: no links or attachments until they reply.
- Set a name, a photo and an about line on the number before it sends anything.
- 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 supported | why |
|---|---|
| Templates, buttons, list messages | Features of the official WhatsApp Business Platform. Use the official platform if you need them. |
| Delete for me | WhatsApp's libraries can read that change but not write it. Delete for everyone works. |
| Votes on polls the account never saw | Votes 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 secret | Not decrypted, so not delivered. |
| Calendar event call links | callType marks an event as a call; the link itself is created by WhatsApp on the phone. |
| Contacts' stories | You can post a story; the ones your contacts post are not received. |
| History on demand | History arrives once, after linking, in the amount WhatsApp decides. |
| Chat changes from the initial sync | Only live changes arrive as chat.updated. |
| Answering or placing calls | Calls 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/sdkimport { 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
| resource | methods |
|---|---|
| proxyLocations | list |
| accounts | list, create, get, update, delete, reconnect, logout, createPairingCode, setPresence, setDefaultDisappearingTimer, waitForQrCode, waitForPairingCode, waitUntilReady |
| messages | send, list, get, edit, delete, react, vote, star, unstar, addLabel, removeLabel |
| chats | sendPresence, sendReadReceipts, markRead, markUnread, archive, unarchive, pin, unpin, mute, unmute, delete, setDisappearingTimer, addLabel, removeLabel |
| stories | create |
| contacts | check, lookup, getPicture, getBusinessProfile, subscribePresence, block, unblock, listBlocked, getLink, resetLink, resolveLink |
| bots | list |
| profile | update, setPicture, deletePicture |
| privacy | get, update, getStoryPrivacy |
| labels | upsert, delete |
| calls | reject |
| stickerPacks, orders | get |
| groups | list, create, get, update, leave, addParticipants, removeParticipants, promoteParticipants, demoteParticipants, getInviteLink, resetInviteLink, join, getInvite, setPicture, deletePicture, listJoinRequests, approveJoinRequests, rejectJoinRequests, listSubgroups, linkSubgroup, unlinkSubgroup, listCommunityParticipants |
| channels | list, create, get, getInvite, follow, unfollow, mute, unmute, listMessages, react, markViewed |
| webhookEndpoints | list, create, get, update, delete, rotateSecret |
| projects | list, create, get, update, delete, getUsage, apiKeys.list, apiKeys.create, apiKeys.revoke |
| invitations | create, list, get, cancel, resend |
| branding | get, update |
| usage | get, byProject |
| client | me(), 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.