Docs
A REST API at https://api.wuapi.dev, a TypeScript SDK, signed Webhooks and Streams, 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. Use Webhooks or Streams 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/wuapi-skillsThe skills live in the public repository wuapidev/wuapi-skills, 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. |
| wuapi-cli | Log in with npx @wuapidev/cli login, link a number and send a test from the terminal, call any API method, set up the MCP server. |
| 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. |
| receive-streams | Streams: live events with no public endpoint. Choosing Webhooks or Streams, resuming with Last-Event-ID, reset, deduplication, filters and limits. |
| 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, 87 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, or OAuth |
| needs | Node 20 or later | a client that sends a custom header or speaks MCP's OAuth |
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 Grok, ChatGPT and Claude.ai do, use the next section.
Connect with OAuth
Chat apps that add remote MCP servers as connectors sign in with OAuth instead of a header. Give them the hosted URL, https://wuapi.dev/api/mcp. Most find the rest on their own: the server's 401 points to its metadata, the app registers itself and opens a wuapi page in your browser. Sign in, pick the organization (all of it or one project) and approve. The app gets an API key named MCP · <app>, listed on API keys. Revoke it there to disconnect the app.
If the app asks for the OAuth settings itself, as Grok's custom connector does, fill them in like this:
| field | value |
|---|---|
| Client ID | wuapi-mcp |
| Client secret | leave it empty |
| Authorization endpoint | https://api.wuapi.dev/oauth/authorize |
| Token endpoint | https://api.wuapi.dev/oauth/token |
| Scopes | wuapi |
| Token auth method | none (PKCE only) |
The consent page shows where the approval goes (the app's address). Approve only a connection you just started. An OAuth key has the full access of an organization or project key: read-only mode is not available through OAuth yet, so use a header client with Wuapi-Read-Only if you need it. The key does not expire. If you revoke it, the app asks you to connect again.
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, update_account, unlink_account, set_presence, set_disappearing_timer, reject_call |
| Messages | send_text, send_media, upload_file, send_location, send_contact, send_poll, send_buttons, reply_to_message, react_to_message, get_message, list_messages, edit_message, delete_message, cancel_message, vote_in_poll, forward_message, star_message |
| Chats | list_chats, get_chat, mark_chat_read, send_read_receipts, archive_chat, pin_chat, mute_chat, delete_chat, manage_labels |
| Contacts | list_contacts, get_contact, check_numbers, lookup_contacts, save_contacts, lookup_whatsapp_info, manage_block_list |
| Profile | manage_profile, manage_favorite_stickers, manage_privacy |
| 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, manage_group_settings, manage_group_joins, manage_community |
| Channels | manage_channel |
| Stories | post_story, manage_stories |
| Webhooks | list_webhooks, get_webhook, create_webhook, update_webhook, delete_webhook |
| Incoming webhooks | list_incoming_webhooks, get_incoming_webhook, create_incoming_webhook, update_incoming_webhook, delete_incoming_webhook |
| Projects | list_projects, get_project, create_project, manage_project |
| Invitations | create_invitation, list_invitations, get_invitation, cancel_invitation, resend_invitation, manage_branding |
| 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. Together they reach every operation of the REST API except the two that return a secret. Tools that read carry readOnlyHint. Tools that delete, revoke, reset, block 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, delete_chat, remove_group_participants, reset_group_invite_link, leave_group, delete_webhook, delete_incoming_webhook, cancel_invitation, resend_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.
Resources with many small operations are one tool with an action argument, so the list stays short enough for a model to pick from. The tool's description lists its actions and each field says which actions use it. Only the destructive actions need confirm: true: unlink_account (logout, delete), manage_labels (delete), manage_block_list (block), manage_profile (delete_picture, reset_contact_link), manage_group_settings (delete_picture), manage_stories (delete), manage_project (delete, revoke_key). In read-only mode a tool that mixes reads and writes keeps only its reading actions. To post to a channel, use send_text or send_media with to set to the channel id.
| tool | actions |
|---|---|
| `unlink_account` | logout, delete |
| `set_presence` | online, offline, typing, recording, paused, subscribe |
| `manage_labels` | upsert, delete, label_chat, unlabel_chat, label_message, unlabel_message |
| `save_contacts` | one, many, backfill_auto_save |
| `lookup_whatsapp_info` | contact_picture, business_profile, resolve_link, bots, sticker_pack, order |
| `manage_block_list` | list, block, unblock |
| `manage_profile` | update, set_picture, delete_picture, get_contact_link, reset_contact_link |
| `manage_favorite_stickers` | list, get_file, add, remove |
| `manage_privacy` | get, get_story_privacy, update |
| `manage_group_settings` | update, set_picture, delete_picture |
| `manage_group_joins` | preview_invite, join, list_requests, approve_requests, reject_requests |
| `manage_community` | create, list_groups, list_members, link_group, unlink_group |
| `manage_channel` | list, get, preview_invite, list_messages, create, follow, unfollow, mute, unmute, react, mark_viewed |
| `manage_stories` | list, list_own, get, viewers, view, react, reply, delete |
| `manage_project` | update, delete, list_keys, revoke_key |
| `manage_branding` | get, update |
Projects, invitations, branding and usage need an organization key. Creating a project API key and rotating a webhook endpoint's signing secret are not tools: both return a secret exactly once, and tool results never carry secrets. Do them in the dashboard or with the SDK. Sending a test webhook event and reading the request log have no public endpoint, so they are not tools either; 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) invite_customer (customer, externalId, email) and use_streams (project, language).
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 |
| Follow the channel at https://whatsapp.com/channel/0029Va... and mute it. | manage_channel (preview_invite, follow, mute) |
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 or rotates a signing secret. - 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.
CLI
The @wuapidev/cli command line logs in from the browser, links a number, runs your app with the key, sends a message, sets up the MCP server, watches your events live with Streams and calls every SDK method from a terminal or a script. It needs Node 20 or later and nothing installed: npx @wuapidev/cli runs the latest version.
npx @wuapidev/cli login
npx @wuapidev/cli link --phone +584241112233
npx @wuapidev/cli send +584241112233 "Hi from the terminal"
npx @wuapidev/cli run -- npm run devLog in
npx @wuapidev/cli login shows a code like BCDF-GHJK and opens https://wuapi.dev/app/cli in your browser (sign in first if you aren't). Check that the page shows the same code, pick the organization and whether the key reaches all of it or one project, and approve. The terminal gets a new API key named CLI · <device name> and stores it in ~/.config/wuapi/credentials.json, readable only by you. The key is listed under API keys like any other and you revoke it there. A code lasts 10 minutes and works once.
--envalso writesWUAPI_API_KEYinto.envin the current folder. That puts the key in a project file: it is for people, not for agents.wuapi rungives your app the key without it.--no-browserprints the link instead of opening it, for a remote shell. Open it on any device where you're signed in.--startprints the code and the link and exits;--finishwaits for the approval and saves the key. An agent uses the pair to hand you the link, and the CLI keeps the key: the agent never sees it. Run both in the same folder; each folder keeps its own pending login, so two agent sessions in different folders do not collide.
Profiles
Each login is a profile: one organization, or one project of it, with its own key. Log in again to add another. wuapi profiles lists them and marks the current one, wuapi switch <name> changes it, and --profile <name> (or WUAPI_PROFILE) picks one for a single command. wuapi logout [<name>] [--all] removes a profile, the current one by default, from the file. The key keeps working until you revoke it under API keys.
Commands
| command | does |
|---|---|
| `wuapi login [--no-browser] [--start|--finish] [--env]` | Log in from the browser and store the key as a profile. The key is never printed. |
| `wuapi logout [<name>] [--all]` | Forget a profile, or all of them. |
| `wuapi whoami` | The key in use: organization, project, key prefix and where it came from. |
| `wuapi profiles` / `wuapi switch <name>` | List the profiles, change the current one. |
| `wuapi link [--phone +E164] [--country XX] [--no-wait]` | Create an invitation: a link (valid 1 day) that opens in the browser, where the person links the number with the QR code or the pairing code shown there. The CLI never shows either, so an agent running it never sees one. --phone prefills the page and sets the exit country, --country picks it otherwise, and the command waits until the account is ready unless --no-wait, which prints {invitationId, url, expiresAt}. |
| `wuapi link --here [--phone +E164] [--country XX] [--open]` | Link in this terminal: a QR code drawn here, or a pairing code with --phone. For a person at the terminal; agents should not use it. |
| `wuapi wait <invitationId | accountId>` | Wait until the number is ready. With an invitation id it fails when the invitation fails, expires or is cancelled. |
| `wuapi run -- <command> [args...]` | Run your app, dev server or tests with WUAPI_API_KEY (and WUAPI_PROJECT for a project profile) in that process's environment only, and exit with its code. No .env needed. |
| `wuapi send <to> <text> [--account id]` | Send a text. With one ready account, --account can be left out. |
| `wuapi events stream [--types a,b] [--accounts id,id] [--count n]` | Streams from the terminal: print each event live as one JSON line on stdout (logs go to stderr), reconnecting with Last-Event-ID and printing an event once even when a replay repeats it. Uses one of your stream connections. |
| `wuapi mcp add [--client claude|cursor|vscode]` | Add the wuapi MCP server to your client. No key goes into its config: the server uses your login. |
| `wuapi <resource> <method> [ids...] [--field value] [--data json|@file] [--all]` | Any SDK method, such as wuapi messages list --account acc_... --all. Path ids go in order, body fields as flags or as JSON with --data, and --all follows every page of a list. wuapi help <resource> lists its methods. |
Every command takes --json and then prints only JSON on stdout, errors included, so scripts and agents can parse it. --project <id> (or WUAPI_PROJECT) acts inside one project with an organization key.
Which key it uses
The first one found: --api-key, then the WUAPI_API_KEY environment variable, then WUAPI_API_KEY in .env in the current folder, then the profile named by --profile or WUAPI_PROFILE, then the current profile. wuapi whoami says which one won.
The key never goes in a command's output. The approval page shows the code and the device name the terminal sent; approve only a login you started 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 every plan, an account that has not linked within 15 minutes stops its session (link_timeout); reconnect it for a new QR code.
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 every plan, link_timeout (not linked within 15 minutes; reconnect for a new QR code) does not either. On the Free plan there is one more: 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. Troubleshooting connections, under Reference, explains each reason, the lines in the account's connection log and what to do.
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?, mediaAutoDownload?, imageQuality?, contactAutoSave?}. Returns the account. Call settings are covered under Calls, pacing under Rate limits and pacing, historySync under History sync, mediaAutoDownload under Received media, imageQuality under Send a message, contactAutoSave under Contacts. |
| 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 and included in the account price, with no per-GB charge, up to a fair use of 5 GB per billable account a month, pooled across the organization at the most accounts billable that month. Subscriptions started before October 18, 2026 keep metered billing: 0.5 GB included per account, then $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 traffic can pause for three reasons. When the organization's traffic reaches the fair use, it pauses until the month ends (UTC). While an invoice is unpaid, it pauses until the invoice is paid. On legacy metered billing, each organization also has a monthly cap on proxy overage ($50 unless an owner or admin changes it in Billing; $0 allows none), and at the cap it pauses until the cap is raised or the month ends. 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 proxy_fair_use_reached, 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 thirteen 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. One exception, a private reply: a message someone else wrote in a group can be quoted in the direct chat with its author (to is that message's from), who sees which group message you are answering. |
| mentions | Contacts to mention, up to 256. |
| mentionAll | true mentions every participant. Groups only. |
| forwarded | true marks this new message as forwarded: the recipient sees the Forwarded label on content you supply. To forward a message wuapi already stores, use POST /v1/messages/{messageId}/forward (see Forwarding a message). |
| 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?}, or {uploadId, mimeType?, filename?} for a file you uploaded (see Sending a file you have). url must be HTTPS; our servers fetch it. A missing mimeType is guessed from the URL, or is the upload's. | text as the caption. viewOnce: true: the recipient can open it once. Video only: media.gifPlayback: true plays it as a GIF. Image only: media.quality: standard, hd or original (see Image quality below). |
| 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. |
| buttons | text (the body) and buttons: 1 to 10 {text, id} (quick reply) or {text, url} (HTTPS link). Labels are up to 20 characters. | footer: small print, up to 256 characters. A tap on a quick reply arrives as a message.received of type button, with button.id naming it. |
| calendar_event | calendarEvent: {name, startsAt}. Times are ISO 8601. | calendarEvent.description, endsAt, location, callType, allowExtraGuests. |
A buttons message takes no replyToMessageId, replyToStoryId, mentions, mentionAll, forwarded or disappearingSeconds: those fields belong to a WhatsApp content message, and a native-flow message carries none of them.
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"
}
}'Image quality. An image is uploaded through the number's proxy, so wuapi sends it the way the WhatsApp apps send a photo: smaller, with no visible difference in a chat. The account's imageQuality sets it for every image, and media.quality on a send overrides it for that image.
standard(the default): the longest side is brought down to 1600 px and the image is sent as a JPEG at quality 80. A 12 MP phone photo of 3 to 5 MB goes out at 150 to 400 KB.hd: up to 4096 px at quality 90, like the HD switch in the apps.original: the file is not re-encoded. Only its metadata (Exif, GPS, embedded thumbnails) is removed; the pixels are the same.- Only
imagemessages and image stories. Adocumentis delivered as the file it is, so send a photo as adocumentwhen the recipient needs the exact file. Stickers, video and audio are never changed. - An image that is already small is left alone, and so is one that would not come out at least 10% smaller. A PNG with transparency stays a PNG, and a flat one (a logo, a screenshot with few colors) too. The color profile and the orientation are kept.
- Change the account's with
PATCH /v1/accounts/{accountId}{"imageQuality": "hd"}or in the dashboard. It applies to the images sent from then on.
{
"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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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.- A
buttonsmessage is a native-flow message: WhatsApp draws up to 10 buttons under the body. A quick reply is disabled in the sender's own view of the message (WhatsApp does not let you answer yourself) and enabled for everyone else; a link button opens its URL. 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, proxy_fair_use_reached 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 or list messages to send: those are features of the official WhatsApp Business Platform. Buttons are not: a native-flow message (type: "buttons") renders from a linked number, and a tap comes back as a message of type button.
Sending a file you have
media.url needs the file at a public URL. For a file you have instead (a local file, a pasted image, a recorded voice note), upload it and send it by its id: media: {uploadId}. Nothing is downloaded from outside when it is sent, because wuapi already stores the file.
| request | effect |
|---|---|
| POST /v1/uploads | {mimeType, size, filename?}: a pending upload with an uploadUrl, for a file of any size up to 100 MB. Or {mimeType, base64, filename?}: the bytes in the request, up to 5 MB, and the upload comes back ready. |
| POST {uploadUrl} | The file's raw bytes, with its Content-Type and no API key. The URL is the credential and works for 1 hour. Answers {storageId}. |
| POST /v1/uploads/{uploadId}/complete | {storageId}. Checks the stored file against the declared size and returns the upload ready. |
| GET /v1/uploads/{uploadId} | The upload: status, size, expiresAt. |
import { readFile } from "node:fs/promises"
const upload = await wuapi.uploads.upload(await readFile("photo.jpg"), { mimeType: "image/jpeg" })
await wuapi.messages.send({ accountId, to: "+584241112233", type: "image", media: { uploadId: upload.id }, text: "From my camera roll" })curl -X POST https://api.wuapi.dev/v1/uploads \
-H "Authorization: Bearer wu_live_..." \
-H "Content-Type: application/json" \
-d '{"mimeType":"audio/ogg; codecs=opus","base64":"T2dnUwACAAAAAAAAAAB3aGF0c2FwcA=="}'- The TypeScript SDK's
uploads.upload(file)takes aBlob,File,Buffer,ArrayBufferor stream and picks the way: one request for a small file, the upload URL for a larger one. It returns thereadyupload. - A
readyupload can be sent any number of times for 24 hours (expiresAt), to any account the key reaches: upload once, send to many chats. After that it answers404; upload the file again. - The upload belongs to your organization, and to the project when the request is scoped to one. Another organization or project gets
404 not_foundfor its id, on reads and on sends. - Every step can be repeated.
POST /v1/uploadswith the sameIdempotency-Keyanswers the same upload and the sameuploadUrl. Completing areadyupload with the samestorageIdanswers it again. A send repeated with the sameIdempotency-Keyreturns the first message; it never fails because the upload was already used. - The message's
media.sizeis the stored file's size at once, andmedia.urlis the stored file: it works until the message is deleted, like a received file's. An uploaded image is sent at the account'simageQuality, or atmedia.quality, exactly as one from a URL: once it is sent,media.size,media.widthandmedia.heightare those of the image as it went out, andmedia.urlis still the file you uploaded. - A sent message keeps its file after the upload expires. An upload that was never sent is deleted with its file when it expires. A
pendingupload expires after 1 hour. - The file posted to
uploadUrlmust be exactly the declaredsize: another size answers400on complete, the file is deleted and the upload stayspending. Over 100 MB answers413 media_too_large. - Limits: 60 uploads per minute and 5 GB per day per organization, then
429 rate_limitedwithRetry-After. Uploading is not billed; sending the file is proxy traffic like any other media. - A voice note is an Ogg/Opus file sent with
type: "voice", as from a URL: nothing is transcoded.
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. |
| GET /v1/messages/{messageId}/media | The message's file: 302 to it, or {url, ...} with ?redirect=false. Downloads a received file still on WhatsApp first (see Received media). |
| 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}/forward | {to}: forward the message to up to 5 chats of the same account. Returns 202 with a list of one queued message per chat (see Forwarding a message). |
| 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.
Forwarding a message
POST /v1/messages/{messageId}/forward forwards a message wuapi stores (one the number received, one sent through the API, or one sent from the phone) to other chats of the same account, the way WhatsApp does it. You name the chats and nothing else: no content and no file. The recipients see the original content with the Forwarded label.
curl -X POST https://api.wuapi.dev/v1/messages/j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p/forward \
-H "Authorization: Bearer wu_live_..." \
-H "Content-Type: application/json" \
-d '{"to":["+584241112233","120363041234567890@g.us"]}'| field | notes |
|---|---|
| to | Required. 1 to 5 chats, each named once: contact ids (+584241112233, plain digits, lid:...) or group ids. Five is WhatsApp's limit for one forward. Usernames, channels and stories are not accepted. |
| Idempotency-Key | Header. A repeated request answers the stored response. If the first request never completed, the key still identifies each chat's message: the retry returns the ones already queued and queues only the missing ones, so no chat gets the forward twice. |
- The answer has one message per chat, in the order of
to. From there each is a message like any other: its own id,forwarded: true, the same events (message.sent,message.delivered,message.read,message.failed), the same pacing, queue timeout and offline tolerance as a send, and it counts as one sent message. - The request is all or nothing: when it is refused, nothing is queued. What can go wrong for one chat afterwards (the recipient has no WhatsApp, the account stays offline) is that message's own
failedstatus. - What is forwarded: text (with its link preview when wuapi stored one), images, videos, GIFs, audio, voice notes (they stay voice notes), documents, stickers, locations and contact cards. Captions are kept.
- What is not: polls, calendar events, reactions, view-once messages, deleted messages and outbound messages that never reached WhatsApp (
queuedorfailed). They answer400 not_forwardablewithdetails.reason. - Stories: a story the account posted is a message, so it forwards to chats like any other. A contact's story does not (
details.reason: story): WhatsApp has no forward for someone else's story. Reply to it withreplyToStoryIdinstead. - Nothing of the original's context travels: no reply quote, no mentions, no disappearing timer.
Media is not uploaded again
A forwarded file is the same file on WhatsApp's servers: the new message names it, as the WhatsApp app does, so nothing is downloaded from WhatsApp and nothing is uploaded to it, whatever the file's size. That holds while WhatsApp still serves the original file. When it no longer does, wuapi uploads the copy it stores; when it never stored one, it first fetches the file from WhatsApp through the number's proxy. Only when neither WhatsApp nor wuapi has the file is the forward refused, with 410 media_expired.
The forwarded message's media is the source's. When wuapi stores the file, both messages point at that one file (media.url is the same, downloaded: true): it is not copied, and it stays until every message that points at it is deleted. When the source was sent with your own media.url, the forward shows that URL. When the file is still only on WhatsApp, the forward is downloaded: false too, with its own GET /v1/messages/{messageId}/media.
Forwarded many times
WhatsApp counts how many times a message was forwarded along its way. Every message carries forwardedManyTimes: true once it went through five forwards, which is when WhatsApp shows the double arrow and Forwarded many times. Forwarding adds one to the count. A message that is forwardedManyTimes can be forwarded to one chat per request, as in the app: more answers 400 invalid_request with details.maxChats: 1.
forwarded: true on POST /v1/messages is a different thing: it labels a new message, with content you supply, as forwarded. Use it when the content is yours and should read as forwarded; use /forward to pass on a message you have.
Received media
Images, videos, audio, documents and stickers a number receives are downloaded on demand: wuapi keeps what WhatsApp needs to fetch the file, and downloads it through the number's proxy only when someone asks for it. A busy group does not spend proxy traffic on files nobody opens, and a file is not lost when the proxy is having a bad moment as it arrives.
Until it is downloaded, the message's media has downloaded: false and media.url is https://api.wuapi.dev/v1/messages/{messageId}/media. That URL needs your API key. The first request downloads the file once and answers 302 to it; every later request, and media.url on the message from then on, points at the stored file, which needs no key. media.downloaded: true means url is the file itself, as for every file stored before on-demand media. Requests for the same file at the same time share one download.
curl -L -o photo.jpg https://api.wuapi.dev/v1/messages/j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p/media \
-H "Authorization: Bearer wu_live_..."mediaAutoDownloadon the account says what is downloaded as soon as it arrives:none(nothing, the default for new accounts),all(everything, what accounts created before on-demand media keep), or{maxBytes, types}withtypesfromimage,video,audio,document,sticker. Change it withPATCH /v1/accounts/{accountId}or in the dashboard. It applies to messages received from then on.- While
media.downloadedisfalse, fetchingmedia.urlneeds the API key (Authorization: Bearer). No-code tools that download a URL without headers must add the key, or callGET /v1/messages/{messageId}/media?redirect=falseand use theurlit returns, which needs none. - Files over 12 MB are never downloaded up front, whatever
mediaAutoDownloadsays: they are fetched on request only. A file larger than wuapi can download answers413 media_too_large. - When a file
mediaAutoDownloadwants up front does not come through as the message arrives (a dropped connection, a bad proxy exit), wuapi retries in the background for about an hour. When it stores the file,message.media_downloadedfires with the message,media.downloadedtrue. - WhatsApp keeps a file for a limited time, about two weeks. A file nobody asked for in that time answers
410 media_expired. - A first download needs the account
ready: while it reconnects the answer is409 account_not_readywithRetry-After. A download that fails on the way (a dropped connection, a slow proxy exit) answers503 media_download_failedwithRetry-After; retrying usually works. - With
Accept: application/jsonalone (the SDKs send it) or?redirect=false, the answer is{object: "media", messageId, url, mimeType, filename, size}instead of the redirect. media.sizeis the file's size in bytes, known before any download.media.widthandmedia.height(pixels) andmedia.durationSecondsarenullfor received files for now: they are set only on an image sent through the API, once it is sent. Size a preview after downloading the file.- A GIF on WhatsApp is a short video: it arrives as
type: "video"withmedia.gifPlayback: true, and a video sent withmedia.gifPlaybackreads the same. Play it looped and muted, with no controls. - A file sent from the phone itself (
source: phone) is handled like a received one. - A download is proxy traffic, metered like any other. The Inbox loads images as they scroll into view and other files on click.
Chats
GET /v1/accounts/{accountId}/chats lists an account's chats, the one with the newest message first: every conversation with a contact, a group or a channel that wuapi stored a message of. GET /v1/accounts/{accountId}/chats/{chatId} returns one. The account does not need to be connected for either.
| field | meaning |
|---|---|
| id | The chat id, the same value as chatId on its messages: a contact id (+584241112233 or lid:...), a group id or a channel id. |
| type | direct, group or channel. |
| name, savedName, profileName, username | name is the one to show: savedName (the name in the phone's address book, else the business name) when there is one, else the group's name or the contact's profileName (their WhatsApp profile name). |
| pictureId | The id of the chat's picture (the contact's profile picture or the group's). It changes when the picture does: keep the picture you downloaded until this differs, then ask GET /contacts/{contactId}/picture with the chat's id. null when wuapi has not seen it yet, when there is no picture or it is hidden from you, and for channels. |
| lastMessage, lastMessageAt | The chat's latest message as a full message, and when it was sent or received. |
| unread, unreadCount | Whether the chat has unread messages or was marked as unread, and how many messages were received and not read on WhatsApp yet. The count goes back to 0 when the account replies, sends read receipts, marks the chat as read or reads it on the phone. |
| pinned, pinnedAt, archived, muted, muteExpiresAt | WhatsApp's state of the chat: the one the actions below change and chat.updated reports. wuapi takes it from what WhatsApp syncs to the linked number, then follows every change; a value it does not know yet is null, never false. pinnedAt is when the chat was pinned, null when it is not pinned or the time is unknown. |
| query | effect |
|---|---|
| archived | true: only the chats WhatsApp reported as archived. false: every other chat. |
| unread | true: only the chats with unread messages or marked as unread. false: every other chat. |
| type | direct, group or channel. |
| q | Search the contact's or group's name, the number, the username and words of recent messages. Results come best match first instead of by date. |
| limit, cursor | Paging, as in every list. |
curl https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/chats?unread=true&limit=20 \
-H "Authorization: Bearer wu_live_..."unread, unreadCount, pinned, archived and muted are null until wuapi has observed them. It learns them from changes as they happen (through the API, on the phone or on another device), not from the sync after linking, so a chat nobody has archived or pinned since the number was linked answers null, not false. archived=false and unread=false include those chats.
Actions
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, and the chat's state in GET .../chats follows at once.
| 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
A story is a WhatsApp Status: a text, image, video or voice post that lasts 24 hours. An account can post stories, read the ones its contacts post, tell a contact it saw theirs, reply and react to them, and see who viewed its own.
Post a story
Queued and paced like a send and stored as a message with chatId stories and chatType story. That message's id is also the story's id.
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.
| call | does |
|---|---|
| GET /stories/own | The stories the account posted in the last 24 hours, newest first, each a story with own: true, its status and viewCount. Stories posted from the phone are included once stories are on for the account. |
| GET /stories/{storyId}/viewers | Who saw one: a list of story_viewer (contactId, viewedAt, reaction), the latest first. Kept for 7 days after the story expires. |
| DELETE /stories/{storyId} | Delete it for everyone (message.deleted). A story still queued is cancelled. |
story.viewed fires once per contact and story, story.reacted when a contact reacts. Viewers are what WhatsApp reports through read receipts: a contact with read receipts off is not listed, and with the account's own readReceipts privacy set to none, nobody is.
Contacts' stories
Once stories are turned on for an account, the stories its contacts post are stored for 24 hours from the time they were posted, apart from messages: they are not counted as received messages, they are in no chat and they never appear in GET /v1/messages. story.received fires when one arrives and story.deleted when its author deletes it early.
curl https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/stories?unviewed=true \
-H "Authorization: Bearer wu_live_..."| call | does |
|---|---|
| GET /stories | A list of story_group: one per contact, the contact with the newest story first, each with its stories oldest first. ?contactId= for one contact, ?unviewed=true for the contacts with a story not seen yet. limit counts contacts. |
| GET /stories/{storyId} | One story: type, text, backgroundColor and font of a text story, media, postedAt, expiresAt, viewedAt. |
| GET /stories/{storyId}/media | The file, like a message's: a 302 to it, or its URL as JSON with ?redirect=false. |
| POST /stories/{storyId}/view | Tell the author the account saw it. Returns the story with viewedAt set. |
| POST /stories/{storyId}/react | {emoji}: react to it; an empty string removes the reaction. 204. |
| POST /v1/messages with `replyToStoryId` | Reply to it: a message to the story's author (to is the story's contactId) that they see as a reply to their story. |
- A story's file is never downloaded when it arrives.
media.downloadedisfalseandmedia.urlis the media endpoint, which downloads the file through the number's proxy the first time it is asked for and keeps it until the story expires. - Viewing is explicit. Listing stories, reading one and downloading its file send nothing to WhatsApp. Only
POST .../viewputs the account on the story's viewer list, and wuapi never calls it on its own. Call it when a person actually opens the story. - WhatsApp ties story views to read receipts. With the account's
readReceiptsprivacy set tonone, a view is recorded for the account's own devices only, the author is not told, and the answer hasauthorNotifiedfalse. - A story the account opened on its phone shows
viewedAttoo, withauthorNotifiednull. - A reaction goes to the story's author alone. That needs the account's own story privacy to be "My contacts" or "My contacts except" without excluding the author; otherwise the call answers
400 not_supportedand nothing is sent. - A message that replies to a story carries
replyToStoryId: your own replies, and a contact's reply to a story the account posted. story_group.mutedsays the account muted that contact's stories on its phone. Muting and unmuting through the API is not supported.- Limits: 5,000 contacts' stories per account and 100 per contact at a time.
Contacts' stories are being turned on account by account. Until they are on for an account, GET .../stories answers an empty list and story.received does not fire; posting works either way.
Contacts
| request (under /v1/accounts/{accountId}) | effect |
|---|---|
| GET /contacts | The account's address book, ordered by saved name: a list of contact. ?q= searches names, usernames and numbers. Read from what wuapi stored, so the account does not need to be connected. |
| GET /contacts/{contactId} | One contact of the address book. 404 not_found for a number that is not saved in it. |
| PATCH /contacts/{contactId} | {savedName, firstName?}. Save the contact on the account's phone, in WhatsApp and in the phone's own address book. Queued: 202 with the contact and phoneSync. Any number can be saved. Takes Idempotency-Key. |
| POST /contacts/batch | {contacts: [{contactId, savedName, firstName?}]}, up to 500. The same, at once: 202 with a batchId and each contact's status. |
| POST /contacts/auto-save/backfill | Queue the auto-save of the contacts of the account's existing direct chats, with its contactAutoSave templates. 202. |
| 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, asked from WhatsApp: a list of contact. |
| GET /contacts/{contactId}/picture | A picture with id and url. {contactId} also takes a group id, for a group's picture. ?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"]}'The contact list is the address book of the linked phone, as WhatsApp synced it to wuapi: every contact the phone has a saved name or a business name for. It arrives when the number is linked (allow up to a few minutes) and follows every contact added, renamed or deleted on the phone after that. People the account only chatted with, without saving them, are not in it: they are in GET /chats. Each contact has savedName, profileName, phone (null when WhatsApp hides the number and only lid is known), and the username and pictureId wuapi has seen so far. about and deviceCount are null in the list: a lookup asks WhatsApp for those, and leaves savedName and profileName null.
curl https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/contacts?q=maria&limit=20 \
-H "Authorization: Bearer wu_live_..."Save contacts on the phone
PATCH /contacts/{contactId} with {savedName} saves a contact on the linked phone, as if it were saved there: it shows in WhatsApp and in the phone's own Contacts app, about a minute after WhatsApp takes it. firstName (the short name the phone shows) defaults to the first word of savedName. Any number with WhatsApp can be saved, not only the ones already in the address book; a lid:<digits> contact needs its number known. The change is queued and answered 202: the contact already carries the new savedName, and phoneSync says where the save stands: pending (queued, or waiting for an account that is reconnecting), synced (WhatsApp took it) or failed, with error.code not_on_whatsapp (the number has no WhatsApp), account_offline (the account went down for good, or stayed offline past its tolerance, before the change was sent) or save_failed. contact.updated fires with the whole contact when it is synced or failed. Saving the same name again changes nothing, so a retry is safe; send Idempotency-Key to get the first answer back. Removing a contact (savedName: null) is not supported: 400 not_supported. POST /contacts/batch queues up to 500 at once.
curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/contacts/%2B584241112233 \
-H "Authorization: Bearer wu_live_..." \
-H "Content-Type: application/json" \
-d '{"savedName":"Maria Gonzalez"}'Contact auto-save saves the contacts of new chats on its own. Turn it on with PATCH /v1/accounts/{accountId} and contactAutoSave: {enabled: true}. trigger is first_inbound (the contact's first message in a new direct chat, the default) or any_chat (any new direct chat, whoever writes first). nameTemplate is the saved name, with {profileName} (the contact's WhatsApp name) and {phone} (E.164); when it needs a profile name the contact has not set, fallbackName is used (default {phone}). Only direct chats with a number count, never groups, channels or stories. A contact the phone already holds a name for is never renamed, and a contact is auto-saved at most once: renamed or deleted on the phone later, it is not written again. Auto-saves go out a few minutes apart, at most dailyLimit (200) per account and UTC day; the rest wait for the next day. POST /contacts/auto-save/backfill queues the contacts of the chats the account already has, with the same rules.
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, and so does the outcome of a save on the phone (phoneSync). 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?, communityId?}. participants are contacts. communityId creates the group inside that community. 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}'Every group says where it belongs: communityId is the id of its community (null when it is in none, and on the community itself) and default is true for the community's announcement group. Both come with GET /groups, so one list is enough to show an account's groups by community. communityId on POST /groups cannot be combined with community: true: 400 invalid_request. It answers 400 not_supported, with nothing created, for an account that cannot create groups inside a community yet.
A group linked to a community or unlinked from it arrives as group.updated, with subgroups_linked or subgroups_unlinked in changes, the subgroups' ids in linked or unlinked and the community in communityId.
| 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.
Message retention
wuapi keeps each stored message for a limited time, then deletes the oldest automatically: 7 days on the Free plan and 30 days on a paid subscription. GET /v1/messages, GET /v1/chats and the Inbox return only messages still inside that window; once a message ages out it is gone and cannot be fetched again (WhatsApp does not re-send old messages). Plan for this: if your agent needs a lasting record, keep messages on your side as the message.received and message.sent webhooks deliver them.
- The window covers every stored message, including conversations pulled in by history sync.
- The extended-retention add-on raises it to 90, 180 or 365 days, chosen by the account owner in the dashboard (Billing → Add-ons). It needs a paid subscription.
- Deletion is automatic and permanent. There is no endpoint to restore a deleted message, and the window is set from Billing, not the API.
- Only the stored message is pruned. Usage counters, and anything you have already received over webhooks, are unaffected.
Favorite stickers
The account's favorite stickers are the star tab of WhatsApp's sticker picker. WhatsApp keeps that list in sync between the phone and its linked devices, so wuapi has it too: a client can show the same stickers the person sees on their phone, favorite a new one and remove one, and the phone follows.
| request (under /v1/accounts/{accountId}) | effect |
|---|---|
| GET /stickers/favorites | A list of favorite_sticker, the last one favorited first. Stored by wuapi: the account need not be ready. |
| GET /stickers/favorites/{stickerId}/media | The sticker's file: a 302 to it, or its URL as JSON with redirect=false. Fetched from WhatsApp the first time, then stored. |
| POST /stickers/favorites | {messageId} (a sticker message of the account) or {uploadId} (a WebP file from POST /v1/uploads). 201 with the favorite. |
| DELETE /stickers/favorites/{stickerId} | Removes the favorite. 204. |
curl https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/stickers/favorites?limit=50 \
-H "Authorization: Bearer wu_live_..."- wuapi reads the list from WhatsApp once after a number is linked, a minute or two after the account is
ready, and after that applies every change the phone or another linked device makes, as it happens. Until that first read the list is empty. A number that was linked before this existed gets its list the same way, without relinking. sticker.favorites_updatedfires when the list changes:reasonaddedwith thesticker,removedwith itsstickerId, orsyncedwith counts when the whole list was read again. One event per change and one for a whole sync, never one per sticker.- A favorite is a reference to a file on WhatsApp.
media.downloadedisfalseandmedia.urlis the endpoint above until someone asks for the file; then wuapi stores it,media.urlis the file itself anddownloadedistrue. The first fetch needs the accountreadyand counts toward the account's 60 first downloads per minute, shared with message media: fetch what a picker shows, not the whole list at once. animatedandemojisarenulluntil wuapi has the file, because WhatsApp's list does not carry them: the file does.lottie: truemarks a vector sticker (application/was), which needs a Lottie player to draw.- WhatsApp does not keep files forever. A favorite whose file is gone answers
410 media_expiredand itsmedia.urlbecomesnull. Favoriting it again from a message or a file gives it a new one. - Adding and removing write to WhatsApp, so the account must be
ready, and the change shows on the phone. Both can be repeated: a sticker that is a favorite already answers the same favorite and sends nothing,Idempotency-Keyreplays the first answer, and removing a removed favorite answers204. At most 30 changes per minute per account. - To favorite a file of your own, upload a WebP (
mimeType: image/webp, at most 2 MB; WhatsApp shows stickers at 512x512) and pass itsuploadId. wuapi uploads it to WhatsApp from the number, which is proxy traffic like sending it. - Favorite stickers are being turned on account by account. For an account that does not have them yet, the list is empty,
sticker.favorites_updateddoes not fire, andPOSTandDELETEanswer400 not_supported. - WhatsApp has no synced list of recent stickers: each app builds its own from the stickers it sent. A client can do the same from the
stickermessages it sends.
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
Changed on 2026-10-15: every contact has phoneSync (null unless a name was saved on the phone through wuapi), and contact.updated also fires when such a save is synced or failed, with the whole contact. See Contacts.
Changed on 2026-09-30: received media is on demand for accounts created from that day (mediaAutoDownload none). In message.received, media.downloaded is false and media.url is GET /v1/messages/{messageId}/media, which needs your API key: send Authorization: Bearer when you download it, or call it with ?redirect=false and use the keyless url it returns. Accounts created before keep mediaAutoDownload all and the old behavior. New event: message.media_downloaded. See Received media.
No public HTTPS endpoint? Use Streams: the same events over one connection your code opens (/docs#streams).
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 rotationIncoming webhooks go the other way: a secret URL that sends a WhatsApp message from one of your numbers to one fixed contact or group, for a script, CI or a monitoring tool that cannot hold an API key. Create them in the dashboard (Integrations, Incoming webhooks, or a chat's menu in the Inbox) or with POST /v1/incoming-webhooks {accountId, name, to}. POST a JSON {"text": "..."} (content works too, with an optional https imageUrl) or a plain text body to the returned url, with no API key. It answers 202 once the message is queued, and the normal outbox applies: pacing, plan limits, and waiting for a reconnect. Repeat the Idempotency-Key header to retry safely (200 with Idempotent-Replayed: true). A hook sends up to 60 messages per minute, an organization can have 50, and PATCH /v1/incoming-webhooks/{incomingWebhookId} with rotateUrl: true replaces a leaked URL.
curl -X POST https://api.wuapi.dev/v1/incoming-webhooks \
-H "Authorization: Bearer wu_live_..." \
-H "Content-Type: application/json" \
-d '{"accountId":"k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr","name":"Deploys","to":"+584121234567"}'{
"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. What each disconnectReason means and what to do about it is in Troubleshooting connections.
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
}
}Streams
Streams deliver the same events as Webhooks over one HTTPS request that your code keeps open: GET https://stream.wuapi.dev/v1/events/stream. Each event is a Server-Sent Events frame whose data is the webhook envelope, unchanged. Use Streams when your code has no public HTTPS endpoint: local development, a desktop or CLI app, a worker behind NAT, an agent. They are included in the Free plan.
Webhooks or Streams
| you have | use |
|---|---|
| A server with a public HTTPS endpoint | Webhooks: signed, and retried when your server is down |
| No public HTTPS endpoint, or you want each event the moment it happens | Streams: one connection your code opens |
| History, or catching up after a `reset` | REST: GET /v1/messages, GET /v1/accounts/{accountId}/chats |
Do not poll for events. Polling with 2 requests every 4 seconds is 43,200 requests a day against the limit of 600 requests a minute per API key, and an event still arrives up to 4 seconds late. A stream connection costs one connect attempt.
Connect
Send the key in the Authorization: Bearer header. A key in the URL is refused with 401 and never logged. Wuapi-Project scopes an organization key to one project, as on every other route; a project key sees its own project. A stream starts from the moment it opens: events from before it are read through REST.
With an SDK the client is one call: events.stream in @wuapidev/sdk (TypeScript) and in the wuapi crate (Rust), from version 0.13. It opens the stream, reconnects with the last id, waits the retry and Retry-After times, deduplicates, and tells you about a reset. It stops only for what waiting cannot fix, such as a refused key. In any other language the rest of this section is the whole client, and the Node.js tab is one written by hand.
import { StreamError } from "@wuapidev/sdk"
const stream = wuapi.events.stream(
{ types: ["message.received", "message.read"] },
{
onStatus: (status) => {
// The cursor was too old: read what you missed through REST (GET /v1/messages). The stream goes on live.
if (status.type === "reset") console.warn("resync:", status.reason)
if (status.type === "reconnecting") console.warn("stream " + status.reason + ", back in " + status.delayMs + " ms")
},
},
)
try {
for await (const event of stream) {
console.log(event.type, event.data.object.id) // typed by the filter
// stream.lastEventId is the cursor: pass it as lastEventId to resume after a restart
}
} catch (err) {
// Only what waiting cannot fix ends the loop: a bad key, a suspended organization, a wrong filter.
if (err instanceof StreamError) console.error(err.kind, err.message)
else throw err
}Frames
Every stream starts with retry: 3000. An event is a frame with id (an opaque cursor: keep it as a string), event (the type) and data (the envelope, one JSON line). A line that starts with a colon is a heartbeat, sent every 15 seconds when nothing else is. It can carry an id too, so a filtered stream still holds a fresh cursor.
Reconnect and resume
Keep the last id. After a disconnect, wait the retry time and reconnect with Last-Event-ID set to it; a client that cannot set that header can pass ?cursor=<id>. Within 28 minutes wuapi replays what you missed, then goes live. Delivery is at-least-once: a replay can repeat an event, so deduplicate on the evt_ id in data, as you do with Webhooks. The server can close a stream at any time, for a deploy or after a long run, and says how long to wait with retry: reconnect with the last id and nothing is lost.
A cursor older than 28 minutes can get a frame event: reset with data: {"reason": "cursor_expired"} and no replay: wuapi keeps events 30 minutes, but it refuses a resume that close to the end of that time, and one past it always gets the frame. A cursor wuapi never issued (cursor_unknown) or one from a period when nothing was kept (not_logged) gets it too. Read what you missed through REST, then carry on: the stream continues live right after the frame.
Filters
The types and accounts query parameters limit a stream to those event types and accounts: up to 50 values each, repeated or comma separated, as in ?types=message.received,message.sent&accounts=<accountId>. Presence events (chat.presence_updated, contact.presence_updated) are not sent on Streams, so naming one in types answers 400, as does webhook.test or a type that does not exist.
Limits
| what | limit |
|---|---|
| Open stream connections per organization | 3 open stream connections on the Free plan. On a paid plan, 25, or 5 per connected account, whichever is greater; to go past that, write to support. Counted across keys and projects. The next one answers 429 with stream_connection_limit and a message that says how to raise the limit; the open ones are not affected |
| Connect attempts | 30 connect attempts a minute per organization, apart from the 600 requests a minute of REST. Past it: 429 with rate_limited and Retry-After |
| Events on an open stream | Cost nothing against either limit |
| Resume window | 28 minutes: a connection that was away for less than this resumes from its last event |
| Events kept | 30 minutes |
Errors before the stream opens
Nothing is streamed and the body is the usual {code, message, details?}:
| status | code | when |
|---|---|---|
| `400` | invalid_request | A filter is invalid (details.field) |
| `401` | unauthorized | No key, a revoked key, or a key in the URL |
| `403` | organization_suspended | The organization is suspended |
| `404` | project_not_found | Wuapi-Project names no project of the organization |
| `429` | stream_connection_limit or rate_limited | Too many open stream connections, or too many attempts. Wait Retry-After seconds |
| `503` | service_unavailable | The stream is temporarily unavailable. Wait Retry-After seconds |
A web app
A browser's native EventSource cannot set the Authorization header, and the key must not go into a URL. A web app goes through its backend: the backend opens the stream with the key and passes the events on to the page. Never put the key in front-end code.
Streams keep events for 30 minutes, a connection can resume within 28, and presence events are never sent. For history, use REST. In a terminal, npx @wuapidev/cli events stream prints each event as one JSON line.
Events
Webhooks and Streams carry the same events, in the same envelope. 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.
41 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. Media is on demand by default: with media.downloaded false, media.url is GET /v1/messages/{messageId}/media, which needs your API key and downloads the file on first use (see Received media).
{
"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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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: text is the new text (the new caption for media), editedAt is set, and previousAttributes.text holds the text before the edit. An edit that reaches wuapi before the message it edits is applied when the message is stored, so message.edited follows its message.received. Several edits: the latest wins.
{
"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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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"
}
}›message.media_downloadedA received file that the account's mediaAutoDownload wanted up front, but that WhatsApp did not hand over when the message arrived (its message.received had media.downloaded false), was stored by a background retry. media.downloaded is now true and media.url is the file itself, no API key needed. Files fetched through your own GET /v1/messages/{messageId}/media do not fire it.
{
"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": "image",
"text": null,
"media": {
"url": "https://files.wuapi.dev/api/storage/3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f",
"mimeType": "image/jpeg",
"filename": null,
"size": 184320,
"width": null,
"height": null,
"durationSeconds": null,
"gifPlayback": false,
"downloaded": true
},
"location": null,
"contact": null,
"contacts": null,
"poll": null,
"calendarEvent": null,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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"
}
}›story.receivedA contact of the account posted a story (a WhatsApp Status). Only for accounts with stories turned on. The story is stored for 24 hours from postedAt, apart from messages: it is not a message.received, it is not counted as a received message and it is in no chat. Its file is on demand: media.downloaded is false and media.url is GET /v1/accounts/{accountId}/stories/{storyId}/media, which needs your API key. Receiving it tells the contact nothing; POST .../stories/{storyId}/view does.
{
"object": {
"object": "story",
"id": "s97fd2k4w8qc1n5x7v3b9yt6r0hjm2ea",
"projectId": null,
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"contactId": "+584241112233",
"own": false,
"profileName": "Maria",
"username": null,
"type": "image",
"text": "New menu this week",
"backgroundColor": null,
"font": null,
"media": {
"url": "https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/stories/s97fd2k4w8qc1n5x7v3b9yt6r0hjm2ea/media",
"mimeType": "image/jpeg",
"filename": null,
"size": 184320,
"width": 1080,
"height": 1920,
"durationSeconds": null,
"downloaded": false
},
"status": "received",
"viewedAt": null,
"authorNotified": null,
"reaction": null,
"viewCount": null,
"postedAt": "2026-09-24T14:02:11.000Z",
"expiresAt": "2026-09-25T14:02:11.000Z",
"deletedAt": null,
"createdAt": "2026-09-24T14:02:11.000Z"
}
}›story.deletedThe author of a story deleted it before its 24 hours ran out. deletedAt is set, the content is cleared, and the story no longer exists (GET answers 404). A story that simply expires fires nothing. The account's own stories are messages: deleting one fires message.deleted.
{
"object": {
"object": "story",
"id": "s97fd2k4w8qc1n5x7v3b9yt6r0hjm2ea",
"projectId": null,
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"contactId": "+584241112233",
"own": false,
"profileName": "Maria",
"username": null,
"type": "image",
"text": null,
"backgroundColor": null,
"font": null,
"media": null,
"status": "received",
"viewedAt": null,
"authorNotified": null,
"reaction": null,
"viewCount": null,
"postedAt": "2026-09-24T14:02:11.000Z",
"expiresAt": "2026-09-25T14:02:11.000Z",
"deletedAt": "2026-09-24T15:10:00.000Z",
"createdAt": "2026-09-24T14:02:11.000Z"
}
}›story.viewedA contact saw a story the account posted, once per contact and story. storyId is the story's id, which is also its message id. It is what WhatsApp reports through read receipts: contacts with read receipts off, and every contact when the account's own readReceipts privacy is none, are not reported.
{
"object": {
"object": "story_viewer",
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"storyId": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
"contactId": "+584241112233",
"viewedAt": "2026-09-24T14:20:05.000Z",
"reaction": null,
"reactedAt": null
}
}›story.reactedA contact reacted to a story the account posted, or changed or removed their reaction (reaction is then null). A reaction from a contact who was not yet a viewer also fires story.viewed.
{
"object": {
"object": "story_viewer",
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"storyId": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
"contactId": "+584241112233",
"viewedAt": "2026-09-24T14:20:05.000Z",
"reaction": "💚",
"reactedAt": "2026-09-24T14:20:30.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,
"footer": null,
"buttons": null,
"button": null,
"mentions": [],
"forwarded": false,
"forwardedManyTimes": false,
"viewOnce": false,
"starred": false,
"replyToMessageId": null,
"replyToStoryId": 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,
"communityId": null,
"default": 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, or a community gained or lost a subgroup. changes lists what moved; fields that did not change are null or empty. For subgroups_linked and subgroups_unlinked, linked and unlinked are the subgroups' ids and communityId is their community: groupId itself, or the other group when WhatsApp reports the change on the subgroup.
{
"object": {
"object": "group_change",
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"groupId": "120363041234567890@g.us",
"communityId": null,
"actorId": "+584241112233",
"added": [
"+584141234567"
],
"removed": [],
"promoted": [],
"demoted": [],
"linked": [],
"unlinked": [],
"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. pictureId of that chat (GET .../chats) and of the contact follows it.
{
"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, and so are savedName and profileName (the address book is GET .../contacts). It also fires when a name saved through wuapi (PATCH .../contacts/{contactId}, the batch, auto-save or the dashboard) reached the phone or failed: then it carries the whole contact, with phoneSync synced or failed. phoneSync is null in the other cases.
{
"object": {
"object": "contact",
"id": "+584241112233",
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"phone": "+584241112233",
"lid": null,
"savedName": null,
"profileName": null,
"username": null,
"about": "At the office until 6",
"pictureId": null,
"businessName": null,
"deviceCount": null,
"phoneSync": 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
}
}›sticker.favorites_updatedThe account's favorite stickers changed. reason added: one sticker was favorited, on the phone, on another device or through the API; sticker is the favorite and stickerId its id. removed: one stopped being a favorite; stickerId names it. synced: the whole list was read from WhatsApp (after linking, or when the phone resynced it) and differs from what wuapi held; added and removed count the differences and no sticker is named: read GET .../stickers/favorites. One event per change, and one for a whole sync.
{
"object": {
"object": "sticker_favorites_change",
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"reason": "added",
"stickerId": "p97f3h6k9m2q5t8w1z4c7e0g3j6n9r2u",
"sticker": {
"object": "favorite_sticker",
"id": "p97f3h6k9m2q5t8w1z4c7e0g3j6n9r2u",
"accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
"mimeType": "image/webp",
"animated": null,
"lottie": false,
"width": 512,
"height": 512,
"size": 23412,
"emojis": null,
"favoritedAt": "2026-09-24T14:00:00.000Z",
"media": {
"url": "https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/stickers/favorites/p97f3h6k9m2q5t8w1z4c7e0g3j6n9r2u/media",
"downloaded": false
}
},
"added": 1,
"removed": 0
}
}›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. Streams never carries it: webhook endpoints only.
{
"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"
}
}Integrations
An integration connects a linked number to a tool your team already uses, with no code on your side: wuapi runs the connection. You set one up from Integrations in the dashboard (owner or admin). Integrations are on paid plans.
| Integration | What it does |
|---|---|
| Chatwoot | Answer the number's WhatsApp chats from a Chatwoot inbox, in both directions. Setup and options: wuapi.dev/docs/integrations/chatwoot |
Chatwoot
Connect a number to Chatwoot and your agents answer its WhatsApp chats from a Chatwoot inbox. Each number gets an API inbox, and messages go both ways: what the number receives shows up in Chatwoot, and what an agent writes there goes out on WhatsApp. Media, delivery and read statuses, and messages sent from the phone are synced too. wuapi hosts the bridge, so there is nothing to install in Chatwoot.
Requirements
- A paid wuapi plan, and the owner or admin role in the organization.
- A Chatwoot user who is an administrator of the Chatwoot account: creating an inbox needs it.
- A Chatwoot that wuapi can reach from the internet over https: Chatwoot Cloud or self-hosted on a public address. A localhost or private network address will not work.
Connect
Open Integrations > Chatwoot in the dashboard and click Connect a number. Nothing is created in Chatwoot until the last step.
- Paste the address of any page of your Chatwoot, as your browser shows it. wuapi checks it is a Chatwoot and shows its version. An address inside your account (it has
/app/accounts/...) lets the next step open your profile directly. - Click Open my Chatwoot profile. In Chatwoot, that is your avatar at the bottom left, then Profile settings.
- Scroll down to Access Token, click Copy and paste the token in the wizard. Chatwoot has no sign-in for apps, so the token is how wuapi acts in it. wuapi stores it encrypted.
- When the token administers several Chatwoot accounts, pick one. The one in the address you pasted is preselected.
- Pick one or more linked numbers. Each gets its own API inbox, named
WhatsApp +<number>, whose webhook points back to wuapi. The last step links to each inbox.
What syncs
- Messages the number receives land in the contact's conversation as incoming messages, with images, audio, video and documents attached. The contact is created on the first message, keyed by the contact id (
+584241112233orlid:...), and joined to an existing Chatwoot contact with the same number. - Messages sent from the phone, and sends through the API, show as outgoing messages in the same conversation.
- A reply an agent writes in the inbox goes out on WhatsApp through the same queue as
POST /v1/messages: pacing applies, and a reply sent while the number reconnects waits for it. Attachments go as media. Private notes stay in Chatwoot. - Each reply shows sent, delivered and read in Chatwoot as WhatsApp reports them. It shows failed only when the send failed for good, with the reason; a reconnect never marks it failed.
Options
Each connected number has its own options on its Chatwoot page in the dashboard.
| Option | Default | What it does |
|---|---|---|
| Sign replies with the agent's name | off | Replies start with the agent's name in bold, so the customer knows who answered. |
| Reopen resolved conversations | on | A new message reopens the contact's last conversation. Off: it starts a new one. |
| Leave group chats out | on | Only direct chats reach Chatwoot. |
Limits
- Group chats are left out by default. Turn off Leave group chats out to bring them in.
- History imported when the number was linked is not copied to Chatwoot: the inbox starts with the next message.
- Media over 25 MB arrives in Chatwoot as a link to the file, not as an attachment.
While Chatwoot is unreachable, wuapi keeps the updates in order and retries for about two hours; the number's page shows the last error. Disconnecting leaves the inbox and its conversations in Chatwoot.
Troubleshooting
| What you see | Why | What to do |
|---|---|---|
| Chatwoot did not accept this access token | The token was copied incomplete, or it was reset in Chatwoot. | Copy it again from Profile settings > Access Token and paste it. |
| Your user must be an administrator | The token belongs to an agent, and creating an inbox needs an administrator. | Use an administrator's token, or ask one to make you an administrator. |
| Replies show failed in Chatwoot | The connection is paused in wuapi, or the organization is no longer on a paid plan. wuapi refuses the reply and Chatwoot marks it failed. | Resume the connection on the number's Chatwoot page, or upgrade in Billing. Send the reply again. |
| wuapi could not reach this address | The Chatwoot is on localhost, a private network or plain http. | Use the public https address of your Chatwoot. |
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: $7 for your first number, $5.25 for every one after, and $4.10 from the 51st. Proxy: proxyBytes is every byte used this month and includedProxyBytes the pool, at the most accounts billable this month: the fair use (5 GB per account), with billableProxyBytes and proxyFeeCents always 0, since proxy is included. On a subscription started before October 18, 2026 the pool is 0.5 GB per account, billableProxyBytes is 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 | not_forwardable | POST /v1/messages/{messageId}/forward of a message WhatsApp does not forward. details.reason: deleted, view_once, not_sent, poll, calendar_event, reaction, unknown, empty, story (a contact's story) or media_not_stored (the file is still only on WhatsApp and this account cannot forward it from there yet: fetch it once with GET /v1/messages/{messageId}/media, then forward). |
| 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_fair_use_reached | Proxy traffic is paused because this month's proxy traffic reached the fair use (5 GB per account, pooled). The same calls are refused until the month ends. details.billingUrl links to Billing; write to support for more. |
| 402 | proxy_spend_cap_reached | Legacy metered billing: 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, its proxy location changed less than 10 minutes ago, or the organization passed 60 uploads per minute or its daily upload allowance. 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_fair_use_reached, proxy_spend_cap_reached, cancelled, media_expired for a forward whose file is gone, 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.
Streams have a limit of their own, so a reconnecting client never spends the REST budget: 30 connect attempts a minute per organization, and 3 open stream connections on the Free plan (25, or 5 per connected account, whichever is greater, on a paid plan). Events on an open stream cost nothing against either. Past the connect limit the answer is 429 with rate_limited and Retry-After. See Streams.
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
Troubleshooting connections
Each account has a connection log on its page in the dashboard, with the last 7 days of drops, reconnects and sync notes. The API shows the same state on the account: status, disconnectReason, reconnecting and lastError. Below are the cases customers ask about most, with what each means for your messages and what to do about it.
Short drops and reconnects
In the log: Disconnected: the connection closed (connection_closed). Reconnecting., followed by Back online after 3.1 s. The reason can also be keepalive_timeout (the connection stopped answering), stream_error or connect_failed. Every number connects through a residential proxy, and those connections drop now and then. wuapi rebuilds the connection on its own, usually within a few seconds, sometimes 10 to 15.
Messages are not lost. While the account reconnects, status leaves ready and reconnecting is true. Sends made meanwhile are accepted, wait for the account and go out once it is back, oldest first. account.disconnected fires only if the account is still not ready after 20 seconds. You don't need to do anything. A few drops an hour are normal.
Contacts not syncing
In the log, lines that start with contacts sync:, such as contacts sync: app state ... needs a key this device does not have; asked the phone for it or contacts sync: app state ... failed an integrity check; asked the phone for a fresh copy. The phone keeps its contact list in an encrypted store that each linked device syncs. Sometimes the device is missing a key for it, or the copy it received does not check out. This happens mostly right after linking, or on numbers that have linked and unlinked many devices.
Sending and receiving are not affected. What depends on the contact list is the names saved in the phone's address book, and the audience of stories: a story goes to the contacts synced from the phone, so with none synced nobody sees it. wuapi asks the phone for the missing key or a fresh copy, at most once a day, and logs a line such as ... recovered (the phone sent a fresh copy) when it arrives. Keep the phone online with WhatsApp open for a minute so it can answer. Until the fresh copy arrives, chat changes (archive, pin, mute, mark read, labels, star) answer 409 state_resyncing with Retry-After; retry after that many seconds. If contacts are still missing after a day, unlink the number and link it again.
QR code expired
In the log: Disconnected: the QR was not scanned in time (qr_timeout). The QR code rotates while it waits, and the session stops when nobody scans it. No messages are involved, since the number is not linked yet. Call POST /v1/accounts/{accountId}/reconnect, or choose Reconnect on the account page, for a new QR code, and scan it from the phone under Settings, Linked devices, Link a device. A pairing code works too and lives about 160 seconds. On every plan, an account that has not linked within 15 minutes stops with link_timeout.
Logged out
In the log: Disconnected: logged out (logged_out). The device was removed from the phone (Linked devices, then the device, then Log out), or WhatsApp ended the linked session, for example after the phone was offline too long (see the last heading). The account does not reconnect on its own and account.disconnected fires at once. Messages waiting for it fail with account_offline and message.failed fires; new sends answer 409 account_not_ready. Reconnect the account to get a new QR code and link the number again. The account keeps its id.
Session replaced
In the log: Disconnected: another session took over (connection_replaced). WhatsApp allows one connection per linked device, and another connection opened with this one. wuapi never opens two for the same device, so this means the link was used somewhere else. The account does not reconnect on its own, and messages waiting for it fail with account_offline. Reconnect the account. If the link is still valid it comes back without a new QR code; otherwise scan the new one. Don't copy or reuse the linked session outside wuapi.
Offline for a long time
In the log: Still offline after 3 min: the messages waiting for the connection failed, and new sends are refused until it is back. Sends wait for a reconnecting account for up to the offline tolerance, currently 3 minutes. Past it, what waited fails with account_offline, new sends answer 409 account_not_ready, and the account keeps trying to reconnect in the background. Separately, a message still in the send queue after the account's queueTimeoutMinutes (default 60, 1 to 1440) fails: with account_offline if the account is not connected then, with rate_limited if it is.
The dashboard's notifications email you when a number stays offline past your threshold (10 minutes by default). Check that the phone is on and online. If the account has strictCity: true, it may be waiting for a free IP in that city (see Proxy location below). Once the account is ready, send the failed messages again. A billing pause (proxy_paused) or the Free plan's limit (free_limit_reached) also keeps accounts offline; those are covered under Accounts and QR linking.
not_on_whatsapp
The message ends failed with error.code not_on_whatsapp, or a live operation answers 400 not_on_whatsapp. Before a first message to someone, wuapi asks WhatsApp whether the number is on it, and this one is not. Nothing is sent and nothing is retried. Check that the number is complete, with its country code. To check a list before sending, use POST /v1/accounts/{accountId}/contacts/check.
rate_limited
A failed message with rate_limited means it waited past queueTimeoutMinutes behind the account's pacing caps, or WhatsApp itself is rate limiting the number. Pacing is off by default, so the first case only happens after you turn a cap on. Raise messagesPerMinute or queueTimeoutMinutes, or spread the sends over more time. If WhatsApp is limiting the number, slow down: see Sending safely. A 429 rate_limited on a request is the API key's limit of 600 requests per minute; wait for Retry-After.
Proxy location and exit IP
Each number exits in the country and city of its proxyLocation. The country is always exact. The city is preferred: when a number needs a new exit and no residential IP is free in that city, it gets one in another city of the same country instead of staying offline. With strictCity: true it waits for an IP in the exact city, so it can stay offline longer. Lines such as exit IP verified in the log show where the number connects from.
A number keeps its exit IP while that exit works. It moves to a new one rarely, when the exit stops working, and never more often than a fixed budget allows, since frequent IP changes are themselves a risk for the number. A move is a reconnect like any other, and WhatsApp may ask the phone to confirm the link. Choose the place where the number's owner actually is, and avoid changing it often: a connected number changes location at most once every 10 minutes.
Keep the phone linked and active
wuapi is a linked device, so the number depends on its phone. WhatsApp unlinks every linked device when the phone has not been online for about 14 days, and the account then shows logged_out. Keep the phone charged and connected, open WhatsApp on it now and then, and leave the wuapi device in Linked devices. The phone does not need to stay open for sending and receiving.
What is not supported
The gaps we know of, each with its reason.
| not supported | why |
|---|---|
| Templates, list messages | Features of the official WhatsApp Business Platform. Use the official platform if you need them. Buttons do work: they are a native-flow message (type: "buttons"). |
| 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. |
| Changing who sees your stories, muting a contact's stories | The story privacy lists and the muted contacts can be read (GET .../privacy/stories, story_group.muted), not changed: change them on the phone. |
| 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, forward, star, unstar, addLabel, removeLabel |
| chats | list, get, sendPresence, sendReadReceipts, markRead, markUnread, archive, unarchive, pin, unpin, mute, unmute, delete, setDisappearingTimer, addLabel, removeLabel |
| stories | create, list, listOwn, get, getMedia, view, react, listViewers, delete |
| contacts | list, get, 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 |
| favoriteStickers | list, add, remove, getMedia |
| 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 |
| incomingWebhooks | list, create, get, update, delete |
| 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.