# wuapi documentation

> WhatsApp API, without the paperwork. Link a WhatsApp number by QR or pairing code, send and receive over REST and signed webhooks. An independent service, not affiliated with WhatsApp.

Source: https://wuapi.dev/docs · OpenAPI: https://wuapi.dev/openapi.json · API version 0.1.0. Base URL `https://api.wuapi.dev`. Authenticate with `Authorization: Bearer $WUAPI_API_KEY`. TypeScript SDK: `npm install @wuapidev/sdk`. Agent skills: `npx skills add wuapidev/wuapi`.

## Contents

- Start: Quickstart, Authentication, Conventions, Coding agents, MCP server
- Accounts: Accounts and QR linking, Linking with a pairing code
- Messages: Sending messages, Reading, editing and deleting, Chats, Stories
- Contacts and profile: Contacts, Profile and privacy
- Groups and channels: Groups, Communities, Channels
- More: Calls, Labels, History sync, Sticker packs, orders and bots
- Receive: Webhooks, Events
- Platforms: Projects, Invitations
- Reference: Errors, Rate limits and pacing, Sending safely (anti-ban recommendations), What is not supported, TypeScript SDK, OpenAPI, Endpoint reference

## Quickstart

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

1. Create an account at wuapi.dev. You start on the Free plan, with no card: 1 number, 2,000 messages and 0.5 GB of proxy traffic a month, with sending and webhooks included. Upgrade under Billing to connect more numbers.
2. Every organization starts with a default API key, shown on the Overview. Copy it, or create another under API keys, and store it as `WUAPI_API_KEY`.
3. Connect an account: open Accounts, choose Connect account, pick the country and city the number exits from, and scan the QR code with WhatsApp on the phone (Settings, Linked devices, Link a device), or link with a pairing code instead. Copy the account ID when it reports `ready`.
4. Send a message with curl, or with the TypeScript SDK after `npm install @wuapidev/sdk`.

curl:

```bash
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."
  }'
```

TypeScript:

```ts
import { Wuapi } from "@wuapidev/sdk"

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

const message = await wuapi.messages.send(
  {
    accountId: "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    to: "+584241112233",
    text: "Your order has shipped.",
  },
  { idempotencyKey: "order-4417-shipped" },
)

console.log(message.id, message.status) // "queued"
```

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

## Authentication

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

```bash
curl https://api.wuapi.dev/v1/me \
  -H "Authorization: Bearer wu_live_..."
```

```json
{
  "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:

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

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

### Names and values

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

### WhatsApp identities

| 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

```bash
npx skills add wuapidev/wuapi
```

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

| skill | covers |
| --- | --- |
| wuapi-rules | Always loaded. What wuapi is and is not, auth, projects, errors, pagination, idempotency, pacing, webhook signing. |
| link-account | Link a number by QR or pairing code, wait for ready, pick the exit country, reconnect or log out. |
| send-message | Every send type and option, replies, mentions, polls, events, edits, reactions, Status and channel posts. |
| receive-webhooks | Endpoints, signature verification, the full event catalog, retries and history sync. |
| groups-and-channels | Groups, communities, join requests, invite links and channels. |
| chats-contacts-profile | Chat actions, read receipts, labels, contacts, blocklist, profile, privacy and calls. |
| projects-and-invitations | A platform on wuapi: projects, project keys, per-project webhooks and usage, invitations, branding. |

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

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

## MCP server

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

|  | local (stdio) | hosted (Streamable HTTP) |
| --- | --- | --- |
| runs | on your machine: `npx -y @wuapidev/mcp` | at `https://wuapi.dev/api/mcp` |
| key | `WUAPI_API_KEY` environment variable | `Authorization: Bearer wu_live_...` header |
| needs | Node 20 or later | a client that sends a custom header |

### Connect a client

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

Claude Code:

```bash
# 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"
```

Claude Desktop:

```json
{
  "mcpServers": {
    "wuapi": {
      "command": "npx",
      "args": [
        "-y",
        "@wuapidev/mcp"
      ],
      "env": {
        "WUAPI_API_KEY": "wu_live_..."
      }
    }
  }
}
```

Cursor:

```json
{
  "mcpServers": {
    "wuapi": {
      "command": "npx",
      "args": [
        "-y",
        "@wuapidev/mcp"
      ],
      "env": {
        "WUAPI_API_KEY": "wu_live_..."
      }
    }
  }
}
```

Cursor (hosted):

```json
{
  "mcpServers": {
    "wuapi": {
      "url": "https://wuapi.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer wu_live_..."
      }
    }
  }
}
```

VS Code:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "wuapi-key",
      "description": "wuapi API key",
      "password": true
    }
  ],
  "servers": {
    "wuapi": {
      "type": "http",
      "url": "https://wuapi.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${input:wuapi-key}"
      }
    },
    "wuapi-local": {
      "type": "stdio",
      "command": "npx",
      "args": [
        "-y",
        "@wuapidev/mcp"
      ],
      "env": {
        "WUAPI_API_KEY": "${input:wuapi-key}"
      }
    }
  }
}
```

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

### Configuration

| stdio variable | hosted header | does |
| --- | --- | --- |
| WUAPI_API_KEY | `Authorization: Bearer ...` | Required. Your API key. |
| WUAPI_PROJECT | `Wuapi-Project` | Act inside one project: its id or `ext:<externalId>`. |
| WUAPI_MCP_READ_ONLY | `Wuapi-Read-Only` | `true` registers only the tools that read. The `--read-only` flag does the same. |
| WUAPI_BASE_URL |  | API base URL. Default `https://api.wuapi.dev`. Must be https, or http on localhost. |

### Tools

| area | tools |
| --- | --- |
| Context | `get_current_key` |
| Accounts | `list_accounts`, `get_account`, `get_account_qr_code`, `create_account`, `request_pairing_code`, `reconnect_account`, `list_proxy_locations` |
| Messages | `send_text`, `send_media`, `send_location`, `send_contact`, `send_poll`, `reply_to_message`, `react_to_message`, `get_message`, `list_messages`, `edit_message`, `delete_message`, `cancel_message` |
| Chats | `mark_chat_read`, `send_read_receipts`, `archive_chat`, `pin_chat`, `mute_chat` |
| Contacts | `check_numbers`, `lookup_contacts` |
| Groups | `list_groups`, `get_group`, `create_group`, `add_group_participants`, `remove_group_participants`, `promote_group_participants`, `demote_group_participants`, `get_group_invite_link`, `reset_group_invite_link`, `leave_group` |
| Stories | `post_story` |
| Webhooks | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook` |
| Projects | `list_projects`, `get_project`, `create_project` |
| Invitations | `create_invitation`, `list_invitations`, `get_invitation`, `cancel_invitation` |
| Usage | `get_usage`, `get_usage_by_project` |

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

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

### Resources and prompts

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

### Examples

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

### Security

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

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

## Accounts and QR linking

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

curl:

```bash
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"}}'
```

TypeScript:

```ts
const account = await wuapi.accounts.create({
  name: "Support line",
  proxyLocation: { country: "VE", city: "caracas" },
})
```

response:

```json
{
  "object": "account",
  "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "projectId": null,
  "name": "Support line",
  "status": "initializing",
  "reconnecting": false,
  "phone": null,
  "profileName": null,
  "proxyLocation": {
    "country": "VE",
    "city": "caracas",
    "strictCity": false
  },
  "qrCodeUrl": null,
  "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-24T14:00:00.000Z",
  "updatedAt": "2026-09-24T14:00:00.000Z"
}
```

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

### Choose where the number exits

Every account connects through a residential proxy with a sticky exit in the place you choose, so a number keeps a consistent network identity. Pick it from `GET /v1/proxy-locations` and pass its `country` and `city` as `proxyLocation` when you create the account. Filter with `?country=` (an ISO 3166-1 alpha-2 code), or search with `?q=`: it matches the city name, the city code, the country name and the ISO code, ignores case and accents, and puts exact and prefix matches first, so `q=bogo` finds Bogotá and `q=sao` finds São Paulo. Browse the whole list, no key needed, at https://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:

```bash
curl -X PATCH https://api.wuapi.dev/v1/accounts/{accountId} \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"proxyLocation":{"strictCity":true}}'
```

TypeScript:

```ts
await wuapi.accounts.update(accountId, { proxyLocation: { strictCity: true } })
```

curl:

```bash
curl https://api.wuapi.dev/v1/proxy-locations?q=bogo \
  -H "Authorization: Bearer wu_live_..."
```

TypeScript:

```ts
for await (const location of wuapi.proxyLocations.list({ q: "bogo" })) {
  console.log(location.city, location.cityName)
}
```

response:

```json
{
  "object": "list",
  "items": [
    {
      "object": "proxy_location",
      "country": "CO",
      "countryName": "Colombia",
      "city": "bogotá",
      "cityName": "Bogotá"
    },
    {
      "object": "proxy_location",
      "country": "ID",
      "countryName": "Indonesia",
      "city": "bogor",
      "cityName": "Bogor"
    }
  ],
  "nextCursor": null
}
```

### Status

| status | meaning |
| --- | --- |
| initializing | The session is starting. |
| qr_ready | Waiting for the QR code to be scanned or the pairing code to be entered. `qrCodeUrl` is set when linking by QR code, `pairingCode` when linking by code. |
| authenticating | The phone accepted the link and is completing it. |
| ready | Linked. You can send. The account is billable from the first time it reaches ready. |
| disconnected | The connection dropped. `disconnectReason` says why. Most reasons reconnect on their own; `reconnecting` is `true` while one does. |
| failed | The session stopped and will not recover on its own. Reconnect to start again. |

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

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

### Other operations

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

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

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

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

## Linking with a pairing code

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

on create:

```
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"}'
```

existing account:

```
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/pairing-code \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"phone":"+584121234567"}'
```

response:

```json
{
  "object": "pairing_code",
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "code": "WZYX-4K2Q",
  "expiresAt": "2026-09-24T14:04:51.000Z"
}
```

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

## Sending messages

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

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

| field | notes |
| --- | --- |
| accountId | Required. The account to send from. |
| to | Required. A contact (`+584241112233`, plain digits, `lid:...`, or the `@username` of a contact the account already chats with), a group id ending in `@g.us`, or a channel id ending in `@newsletter` to post to a channel the account administers. To a channel: `text`, `image`, `video` or `document`, with no `replyToMessageId`, `mentions` or `mentionAll`. |
| type | Required, one of the types below. A body without `type` is sent as `text`. |
| replyToMessageId | A wuapi message ID in the same chat, to quote it. Works with every type. |
| mentions | Contacts to mention, up to 256. |
| mentionAll | `true` mentions every participant. Groups only. |
| forwarded | `true` marks the message as forwarded. |
| disappearingSeconds | `0`, `86400`, `604800` or `7776000`. Match the chat's timer. |
| metadata | Your own string key-value pairs, up to 50 keys, returned on the message and in webhooks. |

| type | requires | also takes |
| --- | --- | --- |
| text | `text`: up to 4,096 characters, not blank. | `linkPreview`: `{url, title, description?, thumbnailBase64?}`. We do not fetch previews; you provide them. `url` must be HTTPS. |
| image, video | `media`: `{url, mimeType?, filename?}`. `url` must be HTTPS; our servers fetch it. A missing `mimeType` is guessed from the URL. | `text` as the caption. `viewOnce: true`: the recipient can open it once. Video only: `media.gifPlayback: true` plays it as a GIF. |
| audio, voice | `media`, as above. `voice` sends the audio as a voice note. | `text`, `viewOnce`. |
| document, sticker | `media`, as above. | `text` as the caption. |
| location | `location`: `{latitude, longitude, name?, address?}`. |  |
| contact | `contact`: `{name, phone}`. |  |
| contacts | `contacts`: 2 to 20 `{name, phone}` cards in one message. |  |
| poll | `poll`: `{name, options, selectableCount?}`. 2 to 12 unique options, each up to 100 characters. | `poll.selectableCount`: `0` (default) lets voters pick any number. |
| calendar_event | `calendarEvent`: `{name, startsAt}`. Times are ISO 8601. | `calendarEvent.description`, `endsAt`, `location`, `callType`, `allowExtraGuests`. |

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

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

location:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "+584241112233",
  "type": "location",
  "location": {
    "latitude": 10.4806,
    "longitude": -66.9036,
    "name": "Office"
  }
}
```

contact:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "+584241112233",
  "type": "contact",
  "contact": {
    "name": "Front desk",
    "phone": "+584121112222"
  }
}
```

reply:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "+584241112233",
  "type": "text",
  "text": "Yes, it ships today.",
  "replyToMessageId": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p"
}
```

channel post:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "120363198765432109@newsletter",
  "type": "text",
  "text": "Doors open at 8."
}
```

### Polls, calendar events and the other types

poll:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "120363041234567890@g.us",
  "type": "poll",
  "poll": {
    "name": "Team dinner?",
    "options": [
      "Thursday",
      "Friday"
    ],
    "selectableCount": 1
  }
}
```

calendar event:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "120363041234567890@g.us",
  "type": "calendar_event",
  "calendarEvent": {
    "name": "Quarterly review",
    "startsAt": "2026-10-02T15:00:00Z",
    "endsAt": "2026-10-02T16:00:00Z",
    "location": {
      "name": "Room 4"
    },
    "callType": "video"
  }
}
```

voice note:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "+584241112233",
  "type": "voice",
  "media": {
    "url": "https://example.com/note.ogg",
    "mimeType": "audio/ogg; codecs=opus"
  }
}
```

mention:

```json
{
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "to": "120363041234567890@g.us",
  "type": "text",
  "text": "@584241112233 can you take this one?",
  "mentions": [
    "+584241112233"
  ]
}
```

```json
{
  "object": "message",
  "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
  "projectId": null,
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "chatId": "+584241112233",
  "chatType": "direct",
  "direction": "outbound",
  "source": "api",
  "from": "+584121234567",
  "to": "+584241112233",
  "profileName": null,
  "username": null,
  "type": "text",
  "text": "Your order has shipped.",
  "media": null,
  "location": null,
  "contact": null,
  "contacts": null,
  "poll": null,
  "calendarEvent": null,
  "mentions": [],
  "forwarded": false,
  "viewOnce": false,
  "starred": false,
  "replyToMessageId": null,
  "status": "queued",
  "error": null,
  "metadata": {
    "orderId": "4417"
  },
  "sentAt": null,
  "editedAt": null,
  "deletedAt": null,
  "createdAt": "2026-09-24T14:02:11.000Z",
  "updatedAt": "2026-09-24T14:02:11.000Z"
}
```

1. `type: "voice"` sends an audio as a voice note. We do not transcode: send ogg/opus.
2. `calendarEvent.callType` (`audio` or `video`) marks the event as a scheduled WhatsApp call. The call link itself is created by WhatsApp on the phone, so `joinUrl` stays `null` on events you send.
3. A poll's votes arrive as `poll.voted` webhooks, and the stored poll keeps the tally in `poll.options[].voteCount`.

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

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

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

## Reading, editing and deleting

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

edit:

```
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."}'
```

vote:

```
curl -X POST https://api.wuapi.dev/v1/messages/j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p/vote \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"options":["Friday"]}'
```

read receipts:

```
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/chats/+584241112233/read \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{}'
```

list:

```
curl https://api.wuapi.dev/v1/messages?accountId=k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr&chatId=%2B584241112233&limit=20 \
  -H "Authorization: Bearer wu_live_..."
```

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

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

## Chats

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

| request (under /v1/accounts/{accountId}) | effect |
| --- | --- |
| POST /chats/{chatId}/presence | `{state}`: `typing`, `recording` or `paused`. Shows or clears the indicator in that chat. |
| POST /chats/{chatId}/archive, /unarchive | Archive or unarchive. |
| POST /chats/{chatId}/pin, /unpin | Pin or unpin. |
| POST /chats/{chatId}/mute, /unmute | `mute` takes `{durationSeconds?}`. Without a duration, or `0`, it mutes until unmuted. |
| POST /chats/{chatId}/mark-read, /mark-unread | Mark the chat read or unread, as the phone's chat list does. Sends no receipts; use `/read` for those. |
| DELETE /chats/{chatId} | `?deleteMedia=true` also deletes the chat's media. Messages stored in wuapi are kept. |
| PUT /chats/{chatId}/disappearing-timer | `{durationSeconds}`: `0` (off), `86400`, `604800` or `7776000`. |
| POST /chats/{chatId}/labels | `{labelId}`. `DELETE /chats/{chatId}/labels/{labelId}` takes it off. WhatsApp Business only. |

curl:

```bash
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}'
```

TypeScript:

```ts
await wuapi.chats.mute(accountId, "+584241112233", { durationSeconds: 28800 })
await wuapi.chats.archive(accountId, "+584241112233")
```

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

## Stories

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

text:

```
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}'
```

image:

```
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/stories \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"type":"image","text":"New stock","media":{"url":"https://example.com/shelf.jpg"}}'
```

TypeScript:

```ts
const story = await wuapi.stories.create(accountId, {
  type: "text",
  text: "Closed today for inventory.",
  backgroundColor: "#1F2937",
})
story.chatId // "stories"
```

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

> Posting only. Your contacts' stories are not received.

## Contacts

| request (under /v1/accounts/{accountId}) | effect |
| --- | --- |
| POST /contacts/check | `{phones}`, up to 50. Which numbers have WhatsApp: a list of `contact_check`. |
| POST /contacts/lookup | `{contactIds}`, up to 50. About text, picture id, business name and device count: a list of `contact`. |
| GET /contacts/{contactId}/picture | A `picture` with `url`. `?preview=true` for the thumbnail. `404 picture_not_found` when there is none, or it is hidden from you. |
| GET /contacts/{contactId}/business-profile | Address, email, categories and hours of a business account. `404 business_profile_not_found` otherwise. |
| POST /contacts/{contactId}/subscribe-presence | Receive `contact.presence_updated` when that contact goes online or offline. Returns `204`. |
| POST /contacts/{contactId}/block, /unblock | Block or unblock. Returns `204`. |
| GET /blocklist | A list of `blocked_contact`: `contactId` as WhatsApp keeps it (often a `lid:`), plus `phone` and `lid` when the account knows them (`null` otherwise). |
| GET /contact-link | The account's own contact link. `POST /contact-link/reset` revokes it and returns a new one. |
| POST /links/resolve | `{kind, code}`: resolve a contact link (`contact`) or a business message link (`business`) to the contact behind it. `404 link_not_found` when it does not exist. `400 not_supported` when WhatsApp does not offer link resolution to this account. |

check:

```
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"]}'
```

response:

```json
{
  "object": "list",
  "items": [
    {
      "object": "contact_check",
      "phone": "+584241112233",
      "onWhatsApp": true,
      "contactId": "+584241112233",
      "businessName": null,
      "username": "maria.gomez"
    },
    {
      "object": "contact_check",
      "phone": "+584141234567",
      "onWhatsApp": false,
      "contactId": null,
      "businessName": null,
      "username": null
    }
  ],
  "nextCursor": null
}
```

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

## Profile and privacy

| request (under /v1/accounts/{accountId}) | effect |
| --- | --- |
| PATCH /profile | `{about?, name?}`. About text up to 139 characters, display name up to 25. Returns `204`: WhatsApp does not let us read it back. |
| PUT /profile/picture | `{url}` (HTTPS) or `{base64}`, a JPEG. Returns a `picture` with its `id`. |
| DELETE /profile/picture | Remove the picture. Returns `204`. |
| GET /privacy | The account's `privacy_settings`. |
| PATCH /privacy | Any of the settings below, for example `{readReceipts: "none"}`. Returns the settings after the change. |
| GET /privacy/stories | Who sees the account's stories: a `story_privacy` with `lists: [{type, contactIds, default}]`. |

```bash
curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/privacy \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"readReceipts":"none"}'
```

| setting | values |
| --- | --- |
| groupAdd | `all`, `contacts`, `contact_blacklist`, `none` |
| lastSeen | `all`, `contacts`, `contact_blacklist`, `none` |
| stories | `all`, `contacts`, `contact_blacklist`, `none` |
| profile | `all`, `contacts`, `contact_blacklist`, `none` |
| readReceipts | `all`, `none` |
| online | `all`, `match_last_seen` |
| callAdd | `all`, `known` |
| messages | `all`, `contacts` |

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

## Groups

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

| request (under /v1/accounts/{accountId}) | effect |
| --- | --- |
| GET /groups | Groups the account belongs to, as a paginated list. |
| POST /groups | `{name, participants, community?}`. `participants` are contacts. Returns `201` with the group. |
| GET /groups/{groupId} | One group with its participants. |
| PATCH /groups/{groupId} | `{name?, description?, announce?, locked?, joinApproval?, memberAddMode?}`. `announce`: only admins send. `locked`: only admins edit the info. `memberAddMode`: `admins` or `all_members`. Returns the group. |
| POST /groups/{groupId}/participants/add, /remove, /promote, /demote | `{contactIds}`. Returns a list with a `participant_result` per contact. |
| GET /groups/{groupId}/join-requests | Pending join requests, when join approval is on. |
| POST /groups/{groupId}/join-requests/approve, /reject | `{contactIds}`. Returns a list of `participant_result`. |
| PUT /groups/{groupId}/picture | `{url}` (HTTPS) or `{base64}`, a JPEG. Returns a `picture`. `DELETE` removes it. |
| GET /groups/{groupId}/invite-link | A `group_invite_link` with `url`. `POST /groups/{groupId}/invite-link/reset` revokes it and returns a new one. |
| POST /groups/join | `{code}`, the code or the full link: join by invite. Returns a `group_join` with `groupId`. `404 invite_not_found` for a dead link. |
| GET /groups/invites/{code} | Preview the group behind an invite code before joining. |
| POST /groups/{groupId}/leave | Leave the group. Returns `204`. |

curl:

```bash
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"]}'
```

TypeScript:

```ts
const results = await wuapi.groups.addParticipants(accountId, "120363041234567890@g.us", ["+584241112233"])
```

response:

```json
{
  "object": "list",
  "items": [
    {
      "object": "participant_result",
      "contactId": "+584241112233",
      "error": null,
      "inviteCode": null
    }
  ],
  "nextCursor": null
}
```

group:

```json
{
  "object": "group",
  "id": "120363041234567890@g.us",
  "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
  "name": "Night shift",
  "description": "Handover notes and schedules.",
  "ownerId": "+584121234567",
  "community": false,
  "locked": false,
  "announce": false,
  "participants": [
    {
      "contactId": "+584121234567",
      "name": "Acme Support",
      "role": "owner"
    },
    {
      "contactId": "+584241112233",
      "name": "Maria",
      "role": "member"
    }
  ],
  "createdAt": "2026-09-01T12:00:00.000Z"
}
```

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`.

create:

```
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}'
```

link a group:

```
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/groups/COMMUNITY_ID/subgroups \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"groupId":"120363041234567890@g.us"}'
```

| 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`. |

post:

```
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."}'
```

read:

```
for await (const post of wuapi.channels.listMessages(accountId, "120363198765432109@newsletter")) {
  console.log(post.id, post.text, post.viewCount)
}
```

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.

auto-reject:

```
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."}'
```

reject one:

```
curl -X POST https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/calls/4B2F9A0C1D3E5F7A/reject \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"from":"+584241112233"}'
```

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. |

```bash
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.

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

1. WhatsApp sends history once, right after linking. Changing `historySync` applies to the next link: a number that is already linked gets no history, not even after a reconnect.
2. With `historySync` `none` nothing is stored and `history.synced` never fires. Contact names and live messages work the same either way.
3. Invitations take `historySync` too; the account the invitee links gets it. It is `none` unless you set it.
4. History arrives in pushes of at most 500 messages. Each push fires one `history.synced` with counts, never one webhook per message.
5. Historical messages carry no media: `type` says what it was and `media` is `null`.
6. Messages already stored are skipped and counted in `duplicateCount`.
7. History does not count toward `sentMessageCount` or `receivedMessageCount` in usage.
8. How much history WhatsApp sends is decided by WhatsApp and the phone. There is no endpoint to ask for more.

## Sticker packs, orders and bots

| request (under /v1/accounts/{accountId}) | returns |
| --- | --- |
| GET /sticker-packs/{stickerPackId} | A `sticker_pack` with its stickers. `404 sticker_pack_not_found`. |
| GET /orders/{orderId}?token= | An `order` a customer sent from a catalog: products, prices and totals in cents. The `token` comes with the order message. `404 order_not_found`. |
| GET /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). |

```bash
curl https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr/orders/ORDER_ID?token=ORDER_TOKEN \
  -H "Authorization: Bearer wu_live_..."
```

## Webhooks

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

TypeScript:

```ts
const endpoint = await wuapi.webhookEndpoints.create({
  url: "https://example.com/webhooks/wuapi",
  events: ["message.received", "message.failed", "account.disconnected"],
})
console.log(endpoint.secret) // whsec_..., returned only on creation and rotation
```

curl:

```bash
curl -X POST https://api.wuapi.dev/v1/webhook-endpoints \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/webhooks/wuapi","events":["message.received"]}'
```

response:

```json
{
  "object": "webhook_endpoint",
  "id": "n97p31n8b5c7z2y4u6ebt5gr1ew3qa8s",
  "projectId": null,
  "url": "https://example.com/webhooks/wuapi",
  "events": [
    "message.received"
  ],
  "active": true,
  "secret": "whsec_3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
  "createdAt": "2026-09-24T14:00:00.000Z",
  "updatedAt": "2026-09-24T14:00:00.000Z"
}
```

```json
{
  "id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
  "object": "event",
  "type": "message.received",
  "createdAt": "2026-09-24T14:02:11.000Z",
  "organizationId": "w82t6y1u5i9o3p7a2s6d0f4g8h2j6k1l",
  "projectId": null,
  "data": {
    "object": {
      "object": "message",
      "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
      "...": "..."
    }
  }
}
```

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

### Delivery

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

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

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

### Verifying the signature

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

SDK:

```ts
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
  }
}
```

Node, no SDK:

```ts
import { createHmac, timingSafeEqual } from "node:crypto"

// header: "t=1727136000,v1=5f2b..."
export function verifyWuapiSignature(rawBody: string, header: string, secret: string, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]))
  const t = Number(parts.t)
  if (!t || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  const a = Buffer.from(expected, "hex")
  const b = Buffer.from(parts.v1, "hex")
  return a.length === b.length && timingSafeEqual(a, b)
}
```

## Events

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

#### Accounts

##### `account.qr_code_issued`

The 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}`.

```json
{
  "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_issued`

A new pairing code was issued for an account linking by phone number. Fires only when the code changes. The code is `pairingCode`.

```json
{
  "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.connected`

The 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.

```json
{
  "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.disconnected`

The 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.

```json
{
  "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.failed`

The session stopped and will not recover on its own. Reconnect to start again.

```json
{
  "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.received`

A contact sent a message to a connected account. Reactions arrive here too, with `type` `reaction`, the emoji in `text` and the target in `replyToMessageId`.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "inbound",
    "source": "contact",
    "from": "+584241112233",
    "to": "+584121234567",
    "profileName": "Maria",
    "username": "maria.gomez",
    "type": "text",
    "text": "Is my order on the way?",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "received",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:10.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
```

##### `message.sent`

WhatsApp accepted an outbound message. Also fires for a message sent from the phone itself, with `source` `phone`, and for stories and channel posts.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "sent",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:12.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
```

##### `message.delivered`

The recipient's device received the message.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "delivered",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:12.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
```

##### `message.read`

The recipient read the message, or played a voice note.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "read",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:12.000Z",
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
```

##### `message.failed`

An outbound message could not be sent. `error.code` is `not_on_whatsapp`, `rate_limited`, `account_offline`, `cancelled` (deleted while still queued) or `send_failed`.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": "Your order has shipped.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "failed",
    "error": {
      "code": "not_on_whatsapp",
      "message": "The recipient is not on WhatsApp."
    },
    "metadata": {},
    "sentAt": null,
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
```

##### `message.edited`

A stored message was edited, by the contact or by you. The row is updated in place, `editedAt` is set, and `previousAttributes.text` holds the text before the edit.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "inbound",
    "source": "contact",
    "from": "+584241112233",
    "to": "+584121234567",
    "profileName": "Maria",
    "username": null,
    "type": "text",
    "text": "Is my order on the way? It is #4417.",
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "received",
    "error": null,
    "metadata": {},
    "sentAt": "2026-09-24T14:02:10.000Z",
    "editedAt": "2026-09-24T14:05:02.000Z",
    "deletedAt": null,
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  },
  "previousAttributes": {
    "text": "Is my order on the way?"
  }
}
```

##### `message.deleted`

A stored message was deleted for everyone. The row is kept, its content is cleared and `deletedAt` is set.

```json
{
  "object": {
    "object": "message",
    "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "projectId": null,
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "chatType": "direct",
    "direction": "outbound",
    "source": "api",
    "from": "+584121234567",
    "to": "+584241112233",
    "profileName": null,
    "username": null,
    "type": "text",
    "text": null,
    "media": null,
    "location": null,
    "contact": null,
    "contacts": null,
    "poll": null,
    "calendarEvent": null,
    "mentions": [],
    "forwarded": false,
    "viewOnce": false,
    "starred": false,
    "replyToMessageId": null,
    "status": "read",
    "error": null,
    "metadata": {},
    "sentAt": null,
    "editedAt": null,
    "deletedAt": "2026-09-24T14:06:40.000Z",
    "createdAt": "2026-09-24T14:02:11.000Z",
    "updatedAt": "2026-09-24T14:02:11.000Z"
  }
}
```

##### `poll.voted`

Someone 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.

```json
{
  "object": {
    "object": "poll_vote",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "120363041234567890@g.us",
    "messageId": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
    "voterId": "+584241112233",
    "options": [
      "Friday"
    ],
    "votedAt": "2026-09-24T14:07:12.000Z",
    "poll": {
      "object": "message",
      "id": "j97d2k1x8p3m4q5w6e7r8t9y0u1i2o3p",
      "projectId": null,
      "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
      "chatId": "120363041234567890@g.us",
      "chatType": "group",
      "direction": "outbound",
      "source": "api",
      "from": "+584121234567",
      "to": "120363041234567890@g.us",
      "profileName": null,
      "username": null,
      "type": "poll",
      "text": null,
      "media": null,
      "location": null,
      "contact": null,
      "contacts": null,
      "poll": {
        "name": "Team dinner?",
        "options": [
          {
            "name": "Thursday",
            "voteCount": 1
          },
          {
            "name": "Friday",
            "voteCount": 3
          }
        ],
        "selectableCount": 1,
        "voterCount": 4
      },
      "calendarEvent": null,
      "mentions": [],
      "forwarded": false,
      "viewOnce": false,
      "starred": false,
      "replyToMessageId": null,
      "status": "read",
      "error": null,
      "metadata": {},
      "sentAt": null,
      "editedAt": null,
      "deletedAt": null,
      "createdAt": "2026-09-24T14:02:11.000Z",
      "updatedAt": "2026-09-24T14:02:11.000Z"
    }
  }
}
```

#### Groups

##### `group.joined`

The account was added to a group or created one.

```json
{
  "object": {
    "object": "group",
    "id": "120363041234567890@g.us",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "name": "Night shift",
    "description": "Handover notes and schedules.",
    "ownerId": "+584121234567",
    "community": false,
    "locked": false,
    "announce": false,
    "participants": [
      {
        "contactId": "+584121234567",
        "name": "Acme Support",
        "role": "owner"
      },
      {
        "contactId": "+584241112233",
        "name": "Maria",
        "role": "member"
      }
    ],
    "createdAt": "2026-09-01T12:00:00.000Z"
  }
}
```

##### `group.updated`

Participants, admins, name, description or settings of a group changed. `changes` lists what moved; fields that did not change are `null` or empty.

```json
{
  "object": {
    "object": "group_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "groupId": "120363041234567890@g.us",
    "actorId": "+584241112233",
    "added": [
      "+584141234567"
    ],
    "removed": [],
    "promoted": [],
    "demoted": [],
    "name": null,
    "description": null,
    "locked": null,
    "announce": null,
    "changes": [
      "participants_added"
    ],
    "changedAt": "2026-09-24T14:07:12.000Z"
  }
}
```

##### `group.join_requested`

Someone asked to join a group with join approval on. Approve with `POST .../groups/{groupId}/join-requests/approve`.

```json
{
  "object": {
    "object": "group_join_request",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "groupId": "120363041234567890@g.us",
    "contactId": "+584241112233",
    "requestedAt": "2026-09-24T14:07:12.000Z"
  }
}
```

##### `group.join_request_revoked`

Someone withdrew their request to join a group.

```json
{
  "object": {
    "object": "group_join_request",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "groupId": "120363041234567890@g.us",
    "contactId": "+584241112233",
    "requestedAt": "2026-09-24T14:07:12.000Z"
  }
}
```

#### Chats and contacts

##### `chat.updated`

A 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.

```json
{
  "object": {
    "object": "chat_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "change": "mute",
    "value": true,
    "messageId": null,
    "mutedUntil": "2026-09-25T14:00:00.000Z"
  }
}
```

##### `chat.presence_updated`

A contact is typing, recording a voice note, or stopped, in a chat. `state` is `typing`, `recording` or `paused`.

```json
{
  "object": {
    "object": "chat_presence",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "contactId": "+584241112233",
    "state": "typing"
  }
}
```

##### `contact.presence_updated`

A contact you subscribed to went online or offline. Subscribe with `POST .../contacts/{contactId}/subscribe-presence`.

```json
{
  "object": {
    "object": "contact_presence",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "contactId": "+584241112233",
    "online": false,
    "lastSeenAt": "2026-09-24T14:05:00.000Z"
  }
}
```

##### `contact.picture_updated`

A contact or group changed or removed its picture. `chatId` is the contact or the group.

```json
{
  "object": {
    "object": "picture_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chatId": "+584241112233",
    "pictureId": "1790258832",
    "removed": false,
    "changedBy": null,
    "changedAt": "2026-09-24T14:07:12.000Z"
  }
}
```

##### `contact.updated`

A 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`.

```json
{
  "object": {
    "object": "contact",
    "id": "+584241112233",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "lid": null,
    "username": null,
    "about": "At the office until 6",
    "pictureId": null,
    "businessName": null,
    "deviceCount": null
  }
}
```

##### `blocklist.updated`

The 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`.

```json
{
  "object": {
    "object": "blocklist_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "changes": [
      {
        "contactId": "+584241112233",
        "action": "block",
        "phone": "+584241112233",
        "lid": "lid:201843727138927"
      }
    ],
    "refetch": false
  }
}
```

##### `label.updated`

WhatsApp 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.

```json
{
  "object": {
    "object": "label_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "kind": "chat",
    "labelId": "3",
    "name": null,
    "color": null,
    "deleted": false,
    "chatId": "+584241112233",
    "messageId": null,
    "labeled": true
  }
}
```

#### Calls

##### `call.received`

An incoming call. Reject it with `POST .../calls/{callId}/reject`, or let `rejectCalls` do it for you.

```json
{
  "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.ended`

A call ended or was rejected. `endReason` says how, when WhatsApp tells.

```json
{
  "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_received`

A new post in a channel the account follows. Forwarded as is, not stored.

```json
{
  "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_updated`

New view and reaction counts for a post in a followed channel. Not stored.

```json
{
  "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.updated`

The account followed, unfollowed, muted or unmuted a channel. `name` is set on `followed`, `role` on `unfollowed`.

```json
{
  "object": {
    "object": "channel_change",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "channelId": "120363198765432109@newsletter",
    "change": "muted",
    "name": null,
    "role": null
  }
}
```

#### History

##### `history.synced`

One 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.

```json
{
  "object": {
    "object": "history_sync",
    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",
    "chunk": 1,
    "syncType": "recent",
    "progress": 42,
    "part": 1,
    "conversationCount": 18,
    "messageCount": 500,
    "duplicateCount": 0
  }
}
```

#### Projects

##### `project.created`

A project was created.

```json
{
  "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.updated`

A project changed: name, external id, metadata, limit, or status (suspended or resumed).

```json
{
  "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.deleted`

The background delete of a project finished: its accounts are logged out and deleted, its keys revoked and its webhook endpoints removed.

```json
{
  "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_changed`

An 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.

```json
{
  "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.test`

Sent 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.

```json
{
  "object": {
    "object": "webhook_endpoint",
    "id": "n97p31n8b5c7z2y4u6ebt5gr1ew3qa8s",
    "projectId": null,
    "url": "https://example.com/webhooks/wuapi",
    "events": [
      "message.received"
    ],
    "active": true,
    "createdAt": "2026-09-24T14:00:00.000Z",
    "updatedAt": "2026-09-24T14:00:00.000Z"
  }
}
```

## Projects

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

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

create:

```
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}'
```

project key:

```
curl -X POST https://api.wuapi.dev/v1/projects/ext:cus_4417/api-keys \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"acme-backend"}'
```

act as a project:

```
curl -X POST https://api.wuapi.dev/v1/accounts \
  -H "Authorization: Bearer wu_live_..." \
  -H "Wuapi-Project: ext:cus_4417" \
  -H "Content-Type: application/json" \
  -d '{"name":"Front desk","proxyLocation":{"country":"MX","city":"mexicocity"}}'
```

response:

```json
{
  "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"
}
```

### 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`. |

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

### Endpoints

| 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:

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

response:

```json
{
  "object": "usage_report",
  "period": {
    "startsAt": "2026-09-01T00:00:00.000Z",
    "endsAt": "2026-10-01T00:00:00.000Z"
  },
  "projects": [
    {
      "projectId": "m17c4e9t2n5a8x0d3f6h9j2k5l8p1q4r",
      "externalId": "cus_4417",
      "name": "Acme Dental",
      "billableAccountCount": 2,
      "accountCount": 2,
      "proxyBytes": 48213094,
      "sentMessageCount": 1840,
      "receivedMessageCount": 2215
    }
  ],
  "unassigned": {
    "billableAccountCount": 1,
    "accountCount": 1,
    "proxyBytes": 1203311,
    "sentMessageCount": 12,
    "receivedMessageCount": 40
  },
  "totals": {
    "billableAccountCount": 3,
    "accountCount": 3,
    "proxyBytes": 49416405,
    "sentMessageCount": 1852,
    "receivedMessageCount": 2255
  }
}
```

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

## Invitations

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

curl:

```bash
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"}}'
```

SDK:

```ts
const invitation = await wuapi.invitations.create({
  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" },
})

// Send invitation.url yourself unless emailSentAt is set.
console.log(invitation.url)
```

response:

```json
{
  "object": "invitation",
  "id": "n28d5f0u3o6b9y1e4g7i0k3m6o9r2s5t",
  "projectId": "m17c4e9t2n5a8x0d3f6h9j2k5l8p1q4r",
  "status": "pending",
  "url": "https://wuapi.dev/invite/7c1e...",
  "inviteeName": "Maria Perez",
  "inviteeEmail": "maria@example.com",
  "inviteePhone": null,
  "suggestedCountry": "MX",
  "proxyLocation": null,
  "methods": [
    "qr_code",
    "pairing_code"
  ],
  "accountName": "Front desk",
  "accountId": null,
  "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": null,
  "startedAt": null,
  "completedAt": null,
  "createdAt": "2026-09-24T14:00:00.000Z",
  "updatedAt": "2026-09-24T14:00:00.000Z"
}
```

| 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

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

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

### Statuses

| 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.

### Email

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

### Branding

curl:

```bash
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"}'
```

response:

```json
{
  "object": "branding",
  "displayName": "Acme Cloud",
  "logoUrl": "https://acme.example/logo.png",
  "accentColor": "#2563EB",
  "supportUrl": null,
  "hideWuapiBranding": false
}
```

`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

```bash
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`.

```json
{
  "code": "invalid_request",
  "message": "`text` is required.",
  "details": {
    "field": "text"
  }
}
```

| status | code | when |
| --- | --- | --- |
| 400 | invalid_request | The body, query, path or cursor failed validation. `details.field` names the field when there is one. |
| 400 | unsupported_proxy_location | `proxyLocation` names a country or city that is not in `GET /v1/proxy-locations`. |
| 400 | not_supported | WhatsApp cannot do that for this account, for example labels on a consumer-app number, or the bot directory and link resolution where WhatsApp does not offer them. |
| 400 | not_on_whatsapp | A live operation named a number that has no WhatsApp. |
| 400 | username_not_supported | A WhatsApp username (`@handle`) this account cannot use: `to` names one it has no chat with, or a field that takes contact ids got one. WhatsApp does not let a linked device look up usernames. |
| 401 | unauthorized | The API key is missing, malformed or revoked, or belongs to a deleted project. |
| 402 | subscription_required | A new account or invitation while the subscription is past due, or a billing add-on without a subscription. `details.billingUrl` links to Billing. |
| 402 | upgrade_required | The Free plan includes 1 connected account: a second account, invitation or reconnect that would take another slot is refused, and so are sends from a Free organization with more than 1 billable account (after a paid subscription ended) until the extra ones are removed. Upgrade in Billing. `details` has `maxAccounts` and `upgradeUrl`, plus `accountId` when the account holding the slot is still linking. |
| 402 | free_limit_reached | The Free plan's monthly limit is reached (2,000 messages sent and received, or 0.5 GB of proxy traffic). Sends, new accounts and reconnects are refused and the account is paused until the month ends (UTC) or the organization upgrades; messages already queued wait up to 24 hours, then fail with this code. `details` has `limit` (`messages` or `proxy`), `month`, `resetsAt` and `upgradeUrl`. |
| 402 | addon_required | `hideWuapiBranding: true` needs the White label add-on. `details` has `addon` and `priceCents`. |
| 402 | trial_proxy_limit_reached | Legacy free trials only (new organizations start on the Free plan): the trial's 0.5 GB of proxy traffic is used up. Sends are refused until the trial ends. `details.upgradeUrl` links to Billing; `details.limitMb` has the cap. |
| 402 | payment_required | Proxy traffic is paused because an invoice is unpaid. Sends, stories, new accounts and reconnects are refused until it is paid; the accounts reconnect on their own then. `details.billingUrl` links to Billing. |
| 402 | proxy_spend_cap_reached | Proxy traffic is paused because this month's proxy overage reached the organization's monthly cap. The same calls are refused until the cap is raised in Billing or the month ends. `details.capCents` has the cap, `details.billingUrl` links to Billing. |
| 403 | forbidden | A project key asked for another project, or for an organization-only route. |
| 403 | project_suspended | A send or write to a suspended project's resources. Reads keep working. |
| 403 | project_limit_reached | The project is at `maxAccounts`. `details.maxAccounts` has the limit. |
| 403 | trial_account_limit | Legacy free trials only: the trial includes one connected account until it converts. `details.upgradeUrl` links to Billing. |
| 403 | whatsapp_forbidden | WhatsApp refused: the account is not a member, not an admin, or not allowed. |
| 404 | not_found | The route, or the resource, does not exist or is outside the key's scope. |
| 404 | project_not_found | The `Wuapi-Project` header or `projectId` names no project in this organization. |
| 404 | group_not_found, channel_not_found, invite_not_found, picture_not_found, business_profile_not_found, link_not_found, sticker_pack_not_found, order_not_found | What was asked for does not exist on WhatsApp, or is hidden from this account. |
| 404 | whatsapp_not_found | WhatsApp does not know the target, in any other case. |
| 409 | account_not_ready | The account is not `ready`, so it cannot send or run live operations. `details.status` has its status when known. |
| 409 | already_linked | A pairing code for an account that is already linked. |
| 409 | already_completed | Cancelling or resending an invitation that is already completed. |
| 409 | already_exists | A project with that `externalId` exists. |
| 409 | idempotency_conflict | The `Idempotency-Key` belongs to a request that is still running, or was used with a different request. |
| 409 | message_sending | `DELETE` or `PATCH` of a queued message while it is being handed to WhatsApp. Retry in a few seconds: it is then `sent` (and the change goes to WhatsApp) or `failed`. |
| 429 | rate_limited | Too many requests, WhatsApp is rate limiting the account, or its proxy location changed less than 10 minutes ago. Wait for the `Retry-After` header. |
| 500 | internal_error | Something failed on our side. Safe to retry a send with the same `Idempotency-Key`. |
| 502 | whatsapp_error | WhatsApp failed the operation. Retrying may work. |
| 503 | engine_unavailable | The connection to WhatsApp is briefly unreachable. Retry shortly. |

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

## Rate limits and pacing

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

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

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

### Set it per account

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

| field | default | recommended | allowed |
| --- | --- | --- | --- |
| `messagesPerMinute` | `0` (off) | `12` | 0 to 30, `0` is off |
| `firstContactPerMinute` | `0` (off) | `5` | 0 to `messagesPerMinute` (to 30 while that is off), `0` is off |
| `typing.enabled` | `false` | `true` | boolean |
| `typing.minMs` | `800` | `800` | 0 to 10000 |
| `typing.maxMs` | `6000` | `6000` | `typing.minMs` to 20000 |
| `typing.charsPerSecond` | `25` | `25` | 5 to 100 |
| `queueTimeoutMinutes` | `60` | `60` | 1 to 1440 |

curl:

```bash
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}}'
```

TypeScript:

```ts
const account = await wuapi.accounts.update(accountId, {
  pacing: { messagesPerMinute: 20, typing: { enabled: true, maxMs: 4000 }, queueTimeoutMinutes: 120 },
})
account.pacing.custom // true

await wuapi.accounts.update(accountId, { pacing: null }) // back to the defaults: every protection off
```

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

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

### When you can leave them off

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

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

### Turn them on

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

curl:

```bash
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}}}'
```

TypeScript:

```ts
await wuapi.accounts.update(accountId, {
  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.

Typing only:

```
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}}}'
```

Slow warm-up:

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

Caps off:

```
curl -X PATCH https://api.wuapi.dev/v1/accounts/k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr \
  -H "Authorization: Bearer wu_live_..." \
  -H "Content-Type: application/json" \
  -d '{"pacing":{"messagesPerMinute":0,"firstContactPerMinute":0}}'
```

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

### Good practice

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

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

## What is not supported

The gaps we know of, each with its reason.

| not supported | why |
| --- | --- |
| Templates, buttons, list messages | Features of the official WhatsApp Business Platform. Use the official platform if you need them. |
| Delete for me | WhatsApp's libraries can read that change but not write it. Delete for everyone works. |
| Votes on polls the account never saw | Votes are encrypted against the poll. For a poll sent before the number was linked here, `poll.voted` arrives with an empty `options` list. |
| Calendar event RSVPs, and reactions or comments encrypted with a message secret | Not decrypted, so not delivered. |
| Calendar event call links | `callType` marks an event as a call; the link itself is created by WhatsApp on the phone. |
| Contacts' stories | You can post a story; the ones your contacts post are not received. |
| History on demand | History arrives once, after linking, in the amount WhatsApp decides. |
| Chat changes from the initial sync | Only live changes arrive as `chat.updated`. |
| Answering or placing calls | Calls can be seen and rejected, not taken. |

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

## TypeScript SDK

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

npm:

```
npm install @wuapidev/sdk
```

pnpm:

```
pnpm add @wuapidev/sdk
```

yarn:

```
yarn add @wuapidev/sdk
```

bun:

```
bun add @wuapidev/sdk
```

```
import { Wuapi } from "@wuapidev/sdk"

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

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

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

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

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

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

### Lists

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

```ts
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.

```ts
import { WuapiError } from "@wuapidev/sdk"

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

### Reference

| resource | methods |
| --- | --- |
| proxyLocations | `list` |
| accounts | `list`, `create`, `get`, `update`, `delete`, `reconnect`, `logout`, `createPairingCode`, `setPresence`, `setDefaultDisappearingTimer`, `waitForQrCode`, `waitForPairingCode`, `waitUntilReady` |
| messages | `send`, `list`, `get`, `edit`, `delete`, `react`, `vote`, `star`, `unstar`, `addLabel`, `removeLabel` |
| chats | `sendPresence`, `sendReadReceipts`, `markRead`, `markUnread`, `archive`, `unarchive`, `pin`, `unpin`, `mute`, `unmute`, `delete`, `setDisappearingTimer`, `addLabel`, `removeLabel` |
| stories | `create` |
| contacts | `check`, `lookup`, `getPicture`, `getBusinessProfile`, `subscribePresence`, `block`, `unblock`, `listBlocked`, `getLink`, `resetLink`, `resolveLink` |
| bots | `list` |
| profile | `update`, `setPicture`, `deletePicture` |
| privacy | `get`, `update`, `getStoryPrivacy` |
| labels | `upsert`, `delete` |
| calls | `reject` |
| stickerPacks, orders | `get` |
| groups | `list`, `create`, `get`, `update`, `leave`, `addParticipants`, `removeParticipants`, `promoteParticipants`, `demoteParticipants`, `getInviteLink`, `resetInviteLink`, `join`, `getInvite`, `setPicture`, `deletePicture`, `listJoinRequests`, `approveJoinRequests`, `rejectJoinRequests`, `listSubgroups`, `linkSubgroup`, `unlinkSubgroup`, `listCommunityParticipants` |
| channels | `list`, `create`, `get`, `getInvite`, `follow`, `unfollow`, `mute`, `unmute`, `listMessages`, `react`, `markViewed` |
| webhookEndpoints | `list`, `create`, `get`, `update`, `delete`, `rotateSecret` |
| projects | `list`, `create`, `get`, `update`, `delete`, `getUsage`, `apiKeys.list`, `apiKeys.create`, `apiKeys.revoke` |
| invitations | `create`, `list`, `get`, `cancel`, `resend` |
| branding | `get`, `update` |
| usage | `get`, `byProject` |
| client | `me()`, `withProject(project)` |

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

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

## OpenAPI

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

## Endpoint reference

Every operation in `https://wuapi.dev/openapi.json`, compact. Base URL `https://api.wuapi.dev`. `*` marks a required field. Error codes are listed per status; every error body is `{code, message, details?}`. Resources carry `object`; lists are `{object: "list", items, nextCursor}`.

### Me

#### `GET /v1/me` — Get the current key
operationId: `getMe`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`; 429 `rate_limited`

### Accounts

#### `GET /v1/accounts` — List accounts
operationId: `listAccounts`
Params: `limit` (query, integer), `cursor` (query, string), `projectId` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`; 429 `rate_limited`

#### `POST /v1/accounts` — Create an account
operationId: `createAccount`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `name` string, `proxyLocation`* object, `pairingPhone` string, `projectId` string, `historySync` object
Errors: 400 `invalid_request`, `unsupported_proxy_location`; 401 `unauthorized`; 402 `upgrade_required`, `free_limit_reached`, `subscription_required`, `payment_required`, `proxy_spend_cap_reached`; 403 `forbidden`, `project_suspended`, `project_limit_reached`, `trial_account_limit`; 404 `project_not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

#### `GET /v1/accounts/{accountId}` — Get an account
operationId: `getAccount`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `PATCH /v1/accounts/{accountId}` — Update an account
operationId: `updateAccount`
Accepts `Wuapi-Project`.
Body: `name` string, `rejectCalls` boolean, `rejectCallsMessage` string, `pacing` AccountPacingUpdate, `historySync` object, `proxyLocation` object
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}` — Delete an account
operationId: `deleteAccount`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `POST /v1/accounts/{accountId}/reconnect` — Reconnect an account
operationId: `reconnectAccount`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 401 `unauthorized`; 402 `free_limit_reached`, `upgrade_required`, `payment_required`, `proxy_spend_cap_reached`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

#### `POST /v1/accounts/{accountId}/logout` — Log an account out
operationId: `logoutAccount`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

#### `POST /v1/accounts/{accountId}/pairing-code` — Create a pairing code
operationId: `createPairingCode`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `phone`* string
Errors: 400 `invalid_request`; 401 `unauthorized`; 402 `free_limit_reached`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `already_linked`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/presence` — Set online or offline
operationId: `setAccountPresence`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `state`* "online" | "offline"
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PUT /v1/accounts/{accountId}/disappearing-timer` — Set the default disappearing timer
operationId: `setDefaultDisappearingTimer`
Accepts `Wuapi-Project`.
Body: `durationSeconds`* 0 | 86400 | 604800 | 7776000
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Proxy locations

#### `GET /v1/proxy-locations` — List proxy locations
operationId: `listProxyLocations`
Params: `country` (query, string), `q` (query, string), `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`; 429 `rate_limited`

### Messages

#### `GET /v1/messages` — List messages
operationId: `listMessages`
Params: `accountId` (query, string), `chatId` (query, string), `direction` (query, MessageDirection), `limit` (query, integer), `cursor` (query, string), `projectId` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`; 429 `rate_limited`

#### `POST /v1/messages` — Send a message
operationId: `sendMessage`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: one of 12, chosen by `type`. One schema per `type`; each requires its own field (`text`, `media`, `location`, `contact`, `contacts`, `poll` or `calendarEvent`). A body without `type` is sent as `text`. To a channel: `text`, `image`, `video` or `document`, with no `replyToMessageId`, `mentions` or `mentionAll`.
- `type: "text"` — SendTextMessageRequest. A text message, with an optional link preview. Fields: `accountId`* string, `to`* string, `text`* string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `linkPreview` SendLinkPreview, `replyToMessageId` string, `metadata` object
- `type: "image"` — SendImageMessageRequest. An image, with an optional caption. Fields: `accountId`* string, `to`* string, `media`* SendMedia, `text` string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `viewOnce` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "video"` — SendVideoMessageRequest. A video, with an optional caption. Fields: `accountId`* string, `to`* string, `media`* SendVideoMedia, `text` string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `viewOnce` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "audio"` — SendAudioMessageRequest. An audio file. Fields: `accountId`* string, `to`* string, `media`* SendMedia, `text` string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `viewOnce` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "voice"` — SendVoiceMessageRequest. An audio sent as a voice note. Send ogg/opus: nothing is transcoded. Fields: `accountId`* string, `to`* string, `media`* SendMedia, `text` string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `viewOnce` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "document"` — SendDocumentMessageRequest. A document, with an optional caption. Fields: `accountId`* string, `to`* string, `media`* SendMedia, `text` string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "sticker"` — SendStickerMessageRequest. A sticker. Fields: `accountId`* string, `to`* string, `media`* SendMedia, `text` string, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "location"` — SendLocationMessageRequest. A location pin. Fields: `accountId`* string, `to`* string, `location`* SendLocation, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "contact"` — SendContactMessageRequest. One contact card. Fields: `accountId`* string, `to`* string, `contact`* SendContact, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "contacts"` — SendContactsMessageRequest. 2 to 20 contact cards in one message. Use `contact` for one. Fields: `accountId`* string, `to`* string, `contacts`* SendContact[], `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "poll"` — SendPollMessageRequest. A poll. Fields: `accountId`* string, `to`* string, `poll`* SendPoll, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
- `type: "calendar_event"` — SendCalendarEventMessageRequest. A calendar event, optionally a scheduled WhatsApp call. Fields: `accountId`* string, `to`* string, `calendarEvent`* SendCalendarEvent, `mentions` string[], `mentionAll` boolean, `forwarded` boolean, `disappearingSeconds` DisappearingSeconds, `replyToMessageId` string, `metadata` object
Errors: 400 `invalid_request`, `username_not_supported`; 401 `unauthorized`; 402 `free_limit_reached`, `upgrade_required`, `payment_required`, `proxy_spend_cap_reached`, `trial_proxy_limit_reached`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`

#### `GET /v1/messages/{messageId}` — Get a message
operationId: `getMessage`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `PATCH /v1/messages/{messageId}` — Edit a message
operationId: `editMessage`
Accepts `Wuapi-Project`.
Body: `text`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `message_sending`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/messages/{messageId}` — Delete a message
operationId: `deleteMessage`
Params: `forEveryone` (query, boolean)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `message_sending`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/messages/{messageId}/react` — React to a message
operationId: `reactToMessage`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `emoji`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/messages/{messageId}/vote` — Vote in a poll
operationId: `voteInPoll`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `options`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/messages/{messageId}/star` — Star a message
operationId: `starMessage`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/messages/{messageId}/unstar` — Unstar a message
operationId: `unstarMessage`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Chats

#### `POST /v1/accounts/{accountId}/chats/{chatId}/presence` — Show typing or recording
operationId: `sendChatPresence`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `state`* "typing" | "recording" | "paused"
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/read` — Send read receipts
operationId: `sendReadReceipts`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `messageIds` string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/mark-read` — Mark a chat read
operationId: `markChatRead`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/mark-unread` — Mark a chat unread
operationId: `markChatUnread`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/archive` — Archive a chat
operationId: `archiveChat`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/unarchive` — Unarchive a chat
operationId: `unarchiveChat`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/pin` — Pin a chat
operationId: `pinChat`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/unpin` — Unpin a chat
operationId: `unpinChat`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/mute` — Mute a chat
operationId: `muteChat`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `durationSeconds` integer
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/chats/{chatId}/unmute` — Unmute a chat
operationId: `unmuteChat`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}/chats/{chatId}` — Delete a chat
operationId: `deleteChat`
Params: `deleteMedia` (query, boolean)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PUT /v1/accounts/{accountId}/chats/{chatId}/disappearing-timer` — Set a chat's disappearing timer
operationId: `setChatDisappearingTimer`
Accepts `Wuapi-Project`.
Body: `durationSeconds`* 0 | 86400 | 604800 | 7776000
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Stories

#### `POST /v1/accounts/{accountId}/stories` — Post a story
operationId: `createStory`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: one of 2, chosen by `type`. A text story (`type: text`, requires `text`) or a media story (`type: image` or `video`, requires `media`). A body without `type` is posted as `text`.
- `type: "text"` — TextStoryCreateRequest. A text story. Fields: `text`* string, `backgroundColor` string, `font` 0 | 1 | 2 | 6 | 7 | 8 | 9 | 10
- `type: "image"` or `type: "video"` — MediaStoryCreateRequest. An image or video story, with an optional caption. Fields: `media`* StoryMedia, `text` string
Errors: 400 `invalid_request`; 401 `unauthorized`; 402 `free_limit_reached`, `upgrade_required`, `payment_required`, `proxy_spend_cap_reached`, `trial_proxy_limit_reached`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`

### Contacts

#### `POST /v1/accounts/{accountId}/contacts/check` — Check numbers on WhatsApp
operationId: `checkContacts`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `phones`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/contacts/lookup` — Look up contacts
operationId: `lookupContacts`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`, `username_not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/contacts/{contactId}/picture` — Get a profile picture
operationId: `getContactPicture`
Params: `preview` (query, boolean)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `picture_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/contacts/{contactId}/business-profile` — Get a business profile
operationId: `getBusinessProfile`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `business_profile_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/contacts/{contactId}/subscribe-presence` — Subscribe to a contact's presence
operationId: `subscribeContactPresence`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/contacts/{contactId}/block` — Block a contact
operationId: `blockContact`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/contacts/{contactId}/unblock` — Unblock a contact
operationId: `unblockContact`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/blocklist` — List blocked contacts
operationId: `listBlockedContacts`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/contact-link` — Get the account's contact link
operationId: `getContactLink`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/contact-link/reset` — Reset the account's contact link
operationId: `resetContactLink`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/links/resolve` — Resolve a contact or business link
operationId: `resolveLink`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `kind`* "contact" | "business", `code`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `link_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/bots` — List WhatsApp AI bots
operationId: `listBots`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Profile

#### `PATCH /v1/accounts/{accountId}/profile` — Update the About text or display name
operationId: `updateProfile`
Accepts `Wuapi-Project`.
Body: `about` string, `name` string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PUT /v1/accounts/{accountId}/profile/picture` — Set the profile picture
operationId: `setProfilePicture`
Accepts `Wuapi-Project`.
Body: exactly one of the following. Exactly one of `url` (https, fetched by our servers) or `base64` (JPEG).
- variant 1. Fields: `url`* string
- variant 2. Fields: `base64`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}/profile/picture` — Delete the profile picture
operationId: `deleteProfilePicture`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Privacy

#### `GET /v1/accounts/{accountId}/privacy` — Get privacy settings
operationId: `getPrivacySettings`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PATCH /v1/accounts/{accountId}/privacy` — Change privacy settings
operationId: `updatePrivacySettings`
Accepts `Wuapi-Project`.
Body: `groupAdd` string, `lastSeen` string, `stories` string, `profile` string, `readReceipts` string, `online` string, `callAdd` string, `messages` string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/privacy/stories` — Get story privacy
operationId: `getStoryPrivacy`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Groups

#### `GET /v1/accounts/{accountId}/groups` — List groups
operationId: `listGroups`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups` — Create a group or community
operationId: `createGroup`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `name`* string, `participants` string[], `community` boolean
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/join` — Join a group by invite
operationId: `joinGroup`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `code`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `invite_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/groups/invites/{code}` — Preview a group invite
operationId: `getGroupInvite`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `invite_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/groups/{groupId}` — Get a group
operationId: `getGroup`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PATCH /v1/accounts/{accountId}/groups/{groupId}` — Update a group
operationId: `updateGroup`
Accepts `Wuapi-Project`.
Body: `name` string, `description` string, `announce` boolean, `locked` boolean, `joinApproval` boolean, `memberAddMode` "admins" | "all_members"
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/leave` — Leave a group
operationId: `leaveGroup`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/participants/add` — Add participants
operationId: `addGroupParticipants`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/participants/remove` — Remove participants
operationId: `removeGroupParticipants`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/participants/promote` — Promote participants
operationId: `promoteGroupParticipants`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/participants/demote` — Demote participants
operationId: `demoteGroupParticipants`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/groups/{groupId}/invite-link` — Get the invite link
operationId: `getGroupInviteLink`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/invite-link/reset` — Reset the invite link
operationId: `resetGroupInviteLink`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PUT /v1/accounts/{accountId}/groups/{groupId}/picture` — Set a group's picture
operationId: `setGroupPicture`
Accepts `Wuapi-Project`.
Body: exactly one of the following. Exactly one of `url` (https, fetched by our servers) or `base64` (JPEG).
- variant 1. Fields: `url`* string
- variant 2. Fields: `base64`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}/groups/{groupId}/picture` — Delete a group's picture
operationId: `deleteGroupPicture`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/groups/{groupId}/join-requests` — List join requests
operationId: `listGroupJoinRequests`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/join-requests/approve` — Approve join requests
operationId: `approveGroupJoinRequests`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/join-requests/reject` — Reject join requests
operationId: `rejectGroupJoinRequests`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `contactIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Communities

#### `GET /v1/accounts/{accountId}/groups/{groupId}/subgroups` — List a community's groups
operationId: `listSubgroups`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/groups/{groupId}/subgroups` — Link a group to a community
operationId: `linkSubgroup`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `groupId`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}/groups/{groupId}/subgroups/{subgroupId}` — Unlink a group from a community
operationId: `unlinkSubgroup`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/groups/{groupId}/community-participants` — List a community's members
operationId: `listCommunityParticipants`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `group_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Channels

#### `GET /v1/accounts/{accountId}/channels` — List followed channels
operationId: `listChannels`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels` — Create a channel
operationId: `createChannel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `name`* string, `description` string, `pictureBase64` string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/channels/invites/{code}` — Preview a channel invite
operationId: `getChannelInvite`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `invite_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/channels/{channelId}` — Get a channel
operationId: `getChannel`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels/{channelId}/follow` — Follow a channel
operationId: `followChannel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels/{channelId}/unfollow` — Unfollow a channel
operationId: `unfollowChannel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels/{channelId}/mute` — Mute a channel
operationId: `muteChannel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels/{channelId}/unmute` — Unmute a channel
operationId: `unmuteChannel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `GET /v1/accounts/{accountId}/channels/{channelId}/messages` — List a channel's messages
operationId: `listChannelMessages`
Params: `limit` (query, integer), `cursor` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels/{channelId}/messages/{channelMessageId}/react` — React to a channel message
operationId: `reactToChannelMessage`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `emoji`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/accounts/{accountId}/channels/{channelId}/mark-viewed` — Mark channel messages viewed
operationId: `markChannelMessagesViewed`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `channelMessageIds`* string[]
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `channel_not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Calls

#### `POST /v1/accounts/{accountId}/calls/{callId}/reject` — Reject an incoming call
operationId: `rejectCall`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `from`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Sticker packs

#### `GET /v1/accounts/{accountId}/sticker-packs/{stickerPackId}` — Get a sticker pack
operationId: `getStickerPack`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `sticker_pack_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Orders

#### `GET /v1/accounts/{accountId}/orders/{orderId}` — Get an order
operationId: `getOrder`
Params: `token`* (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `order_not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Labels

#### `POST /v1/accounts/{accountId}/chats/{chatId}/labels` — Label a chat
operationId: `addChatLabel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `labelId`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}/chats/{chatId}/labels/{labelId}` — Remove a label from a chat
operationId: `removeChatLabel`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `PUT /v1/accounts/{accountId}/labels/{labelId}` — Create or edit a label
operationId: `upsertLabel`
Accepts `Wuapi-Project`.
Body: `name`* string, `color` integer
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/accounts/{accountId}/labels/{labelId}` — Delete a label
operationId: `deleteLabel`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `POST /v1/messages/{messageId}/labels` — Label a message
operationId: `addMessageLabel`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `labelId`* string
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`, `idempotency_conflict`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

#### `DELETE /v1/messages/{messageId}/labels/{labelId}` — Remove a label from a message
operationId: `removeMessageLabel`
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`, `not_supported`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`, `whatsapp_forbidden`; 404 `project_not_found`, `not_found`, `whatsapp_not_found`; 409 `account_not_ready`; 429 `rate_limited`; 500 `internal_error`; 502 `whatsapp_error`; 503 `engine_unavailable`

### Projects

#### `GET /v1/projects` — List projects
operationId: `listProjects`
Params: `limit` (query, integer), `cursor` (query, string), `externalId` (query, string), `status` (query, ProjectStatus)
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 429 `rate_limited`

#### `POST /v1/projects` — Create a project
operationId: `createProject`
Accepts `Idempotency-Key`.
Body: `name`* string, `externalId` string, `metadata` object, `maxAccounts` integer
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 409 `already_exists`, `idempotency_conflict`; 429 `rate_limited`

#### `GET /v1/projects/{projectId}` — Get a project
operationId: `getProject`
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 429 `rate_limited`

#### `PATCH /v1/projects/{projectId}` — Update, suspend or resume a project
operationId: `updateProject`
Body: `name` string, `externalId` string | null, `metadata` object, `maxAccounts` integer | null, `status` ProjectStatus
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 409 `already_exists`; 429 `rate_limited`

#### `DELETE /v1/projects/{projectId}` — Delete a project
operationId: `deleteProject`
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 429 `rate_limited`

#### `GET /v1/projects/{projectId}/api-keys` — List a project's API keys
operationId: `listProjectApiKeys`
Params: `limit` (query, integer), `cursor` (query, string)
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 429 `rate_limited`

#### `POST /v1/projects/{projectId}/api-keys` — Create a project API key
operationId: `createProjectApiKey`
Accepts `Idempotency-Key`.
Body: `name`* string
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

#### `DELETE /v1/projects/{projectId}/api-keys/{apiKeyId}` — Revoke a project API key
operationId: `revokeProjectApiKey`
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 429 `rate_limited`

#### `GET /v1/projects/{projectId}/usage` — Get a project's usage
operationId: `getProjectUsage`
Params: `month` (query, string)
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `not_found`; 429 `rate_limited`

### Invitations

#### `GET /v1/invitations` — List invitations
operationId: `listInvitations`
Params: `status` (query, InvitationStatus), `limit` (query, integer), `cursor` (query, string), `projectId` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`; 429 `rate_limited`

#### `POST /v1/invitations` — Create an invitation
operationId: `createInvitation`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `projectId` string, `accountName` string, `inviteeName` string, `inviteeEmail` string, `inviteePhone` string, `suggestedCountry` string, `proxyLocation` object, `methods` InvitationMethod[], `historySync` object, `returnUrl` string, `expiresInDays` integer, `metadata` object
Errors: 400 `invalid_request`, `unsupported_proxy_location`; 401 `unauthorized`; 402 `upgrade_required`, `free_limit_reached`, `subscription_required`; 403 `forbidden`, `project_suspended`, `project_limit_reached`, `trial_account_limit`; 404 `project_not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

#### `GET /v1/invitations/{invitationId}` — Get an invitation
operationId: `getInvitation`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `POST /v1/invitations/{invitationId}/cancel` — Cancel an invitation
operationId: `cancelInvitation`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `already_completed`, `idempotency_conflict`; 429 `rate_limited`

#### `POST /v1/invitations/{invitationId}/resend` — Resend an invitation
operationId: `resendInvitation`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `already_completed`, `idempotency_conflict`; 429 `rate_limited`

### Branding

#### `GET /v1/branding` — Get branding
operationId: `getBranding`
Errors: 401 `unauthorized`; 403 `forbidden`; 429 `rate_limited`

#### `PATCH /v1/branding` — Update branding
operationId: `updateBranding`
Body: `displayName` string, `logoUrl` string | null, `accentColor` string | null, `supportUrl` string | null, `hideWuapiBranding` boolean
Errors: 400 `invalid_request`; 401 `unauthorized`; 402 `addon_required`; 403 `forbidden`; 429 `rate_limited`

### Webhooks

#### `GET /v1/webhook-endpoints` — List webhook endpoints
operationId: `listWebhookEndpoints`
Params: `limit` (query, integer), `cursor` (query, string), `projectId` (query, string)
Accepts `Wuapi-Project`.
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`; 429 `rate_limited`

#### `POST /v1/webhook-endpoints` — Create a webhook endpoint
operationId: `createWebhookEndpoint`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Body: `url`* string, `events`* WebhookEventType[], `projectId` string
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

#### `GET /v1/webhook-endpoints/{webhookEndpointId}` — Get a webhook endpoint
operationId: `getWebhookEndpoint`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `PATCH /v1/webhook-endpoints/{webhookEndpointId}` — Update a webhook endpoint
operationId: `updateWebhookEndpoint`
Accepts `Wuapi-Project`.
Body: `url` string, `events` WebhookEventType[], `active` boolean
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `DELETE /v1/webhook-endpoints/{webhookEndpointId}` — Delete a webhook endpoint
operationId: `deleteWebhookEndpoint`
Accepts `Wuapi-Project`.
Errors: 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 429 `rate_limited`

#### `POST /v1/webhook-endpoints/{webhookEndpointId}/rotate-secret` — Rotate the signing secret
operationId: `rotateWebhookEndpointSecret`
Accepts `Wuapi-Project` and `Idempotency-Key`.
Errors: 401 `unauthorized`; 403 `forbidden`, `project_suspended`; 404 `project_not_found`, `not_found`; 409 `idempotency_conflict`; 429 `rate_limited`

### Usage

#### `GET /v1/usage` — Get usage
operationId: `getUsage`
Errors: 401 `unauthorized`; 403 `forbidden`; 429 `rate_limited`

#### `GET /v1/usage/by-project` — Get usage by project
operationId: `getUsageByProject`
Params: `month` (query, string)
Errors: 400 `invalid_request`; 401 `unauthorized`; 403 `forbidden`; 429 `rate_limited`
