# WhatsApp

Source: https://staging.postgale.com/docs/whatsapp

Connect WhatsApp Business numbers and send and receive WhatsApp messages through the Postgale REST API and MCP server.

Postgale connects WhatsApp Business phone numbers through Meta's Cloud API and lets your backend or AI agent send and receive
WhatsApp messages: text, media, templates, buttons and lists, locations and reactions, with delivery and read statuses as webhooks,
plus broadcasts and contacts, Flows, groups, Business Calling, the business profile, commerce and Click-to-WhatsApp conversions.
Everything here is available over the [REST API](https://staging.postgale.com/docs/api) (paths relative to `https://staging.postgale.com/api/v1`, Bearer API key) and
the [MCP server](https://staging.postgale.com/docs/mcp) (tools `whatsapp_*`). Each connected number is one account with platform
`whatsapp`; WhatsApp accounts cannot publish posts.

## Connect a number

**Embedded Signup** (recommended): ask for a connect link and send the number's owner to it. The link opens a Postgale
page that runs Meta's Embedded Signup, where they log in with Facebook, pick or create the WhatsApp Business Account (WABA) and
verify the phone number. Postgale then exchanges the code, subscribes the app to the WABA's webhooks, registers the number for
the Cloud API and emits `account.connected`. With several numbers and no choice made in the signup, the result is a pending
connection (`connection.pending`, then `POST /connect/pending/{id}/select`).

GET /api/v1/connect/whatsapp:
```bash
curl https://staging.postgale.com/api/v1/connect/whatsapp?externalId=cust-1 \
  -H 'Authorization: Bearer sk_…'

# {"authUrl":"https://staging.postgale.com/connect/whatsapp?state=…","expiresInSeconds":600}
```

**Manual connect**, for your own numbers or testing: pass a token you already have (a system user token from Meta Business
Settings, or the temporary token from WhatsApp Manager → API Setup) with the phone number id and WABA id. Postgale reads both with the
token, subscribes and registers (`register: false` skips registration; `pin` sets the 6-digit two-step
verification PIN, otherwise a random one is generated). The token is stored encrypted and never returned.

POST /api/v1/whatsapp/connect:
```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/connect \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"accessToken":"EAAG…","phoneNumberId":"106540352242922","wabaId":"102290129340398","externalId":"cust-1"}'
```

The response holds the account and its `whatsapp` view: display number, verified name, quality rating, messaging limit
tier and the onboarding state. When registration failed (`onboarding.registerError`), retry with
`POST /whatsapp/accounts/{id}/register` and an optional `{"pin":"123456"}`.
`GET /whatsapp/accounts/{id}/phone-number?refresh=true` reads the current values from Meta.

## The 24-hour window

- WhatsApp only allows free-form messages (text, media, interactive, location, reactions) within 24 hours of the person's last message. Every inbound message reopens the window; each conversation reports it as `messagingWindow`.
- Outside the window, or when the person never wrote, only an approved **template** can be sent. Postgale answers `409 outside_messaging_window` before calling Meta, so nothing is sent or billed.
- Templates (business-initiated messages) are charged by Meta per message; replies within the window are free. There is no `HUMAN_AGENT` tag on WhatsApp.
- While approval applies to the key, organization or account, sends are queued for a person: `202 {id, status: "pending_approval"}`, sent once an owner or admin approves them (see [Approval](https://staging.postgale.com/docs/api/authentication#approval)).

## Send messages

`POST /whatsapp/accounts/{id}/messages` with the person as `to` (phone number in international format),
`recipient` (business-scoped user id) or `conversationId`, plus `type` and its fields.
`replyTo` (a wamid) quotes an earlier message. Inside a conversation, `POST /inbox/conversations/{id}/messages`
takes the same fields without `to` (the type is inferred). The answer (201) is the stored message and conversation; the
message is a **live** WhatsApp message to a real person.

### Text

POST /api/v1/whatsapp/accounts/{id}/messages:
```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"text","text":"Yes, the blue one ships tomorrow: https://shop.example/blue","previewUrl":true}'
```

### Image, video, audio, document, sticker

One of `mediaUrl` (public https), `whatsappMediaId` (uploaded, see [Media](#media) below) or `mediaId` (a Postgale
upload with a public URL); `caption` for image, video and document, `filename` for documents.

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"document","mediaUrl":"https://shop.example/invoices/1234.pdf","filename":"invoice-1234.pdf","caption":"Your invoice"}'
```

### Template

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"template","template":{"name":"order_shipped","language":"en_US","components":[{"type":"body","parameters":[{"type":"text","text":"Sheena"},{"type":"text","text":"#1234"}]}]}}'
```

### Interactive: reply buttons, list, call-to-action URL

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"interactive","interactive":{"type":"button","body":"Is this delivery time OK?","buttons":[{"id":"yes","title":"Yes"},{"id":"later","title":"Later please"}]}}'
```

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"interactive","interactive":{"type":"list","body":"Pick a size","buttonText":"Sizes","sections":[{"title":"In stock","rows":[{"id":"s","title":"Small"},{"id":"m","title":"Medium","description":"2 left"}]}]}}'
```

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"interactive","interactive":{"type":"cta_url","body":"Track your parcel","displayText":"Track","url":"https://shop.example/track/1234"}}'
```

Limits: up to 3 buttons (titles ≤20 characters), up to 10 list rows, body ≤1024 characters. Button and list replies arrive as `message.received` with type `interactive` and the id you set.

### Location and reaction

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"location","location":{"latitude":52.3676,"longitude":4.9041,"name":"Postgale Store","address":"Damrak 1, Amsterdam"}}'
```

```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"to":"+15551234567","type":"reaction","reaction":{"messageId":"wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y","emoji":"👍"}}'
```

Mark an inbound message read (blue ticks), optionally with a typing indicator while you prepare the answer:
`POST /whatsapp/accounts/{id}/messages/{wamid}/read` with `{"typing":true}`.
`POST /inbox/conversations/{id}/read` marks the latest inbound message read.

## Templates

- `GET /whatsapp/accounts/{id}/templates`: the WABA's templates (`?status=APPROVED&name&category&language&limit&after`). Only `APPROVED` templates can be sent.
- `POST /whatsapp/accounts/{id}/templates`: submit a template for review; it starts as `PENDING` and the decision arrives as `template.status`.
- `GET /whatsapp/accounts/{id}/templates/{templateId}` and `DELETE` (this language; `?allLanguages=true` deletes every language of the name).

POST /api/v1/whatsapp/accounts/{id}/templates:
```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/templates \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"name":"order_shipped","language":"en_US","category":"UTILITY","components":[{"type":"BODY","text":"Hi {{1}}, your order {{2}} has shipped.","example":{"body_text":[["Sheena","#1234"]]}}]}'
```

## Media

- `POST /whatsapp/accounts/{id}/media` with `{"mediaId":"…"}` (a Postgale upload from `POST /media`) or `{"url":"https://…"}` (fetched by Postgale, at most 100 MB) uploads the file to WhatsApp and returns its id; send it with `whatsappMediaId`. Meta keeps uploads for 30 days.
- `GET /whatsapp/accounts/{id}/media/{waMediaId}` returns `{id, mimeType, sha256, size}`; add `?download=1` to stream the bytes (inbound images, voice notes and documents carry such ids in their attachments). Meta's own media URLs need the business token and expire after 5 minutes, so always download through Postgale.
- `DELETE` on the same path removes the media from WhatsApp.

GET /api/v1/whatsapp/accounts/{id}/media/{waMediaId}?download=1:
```bash
curl -o photo.jpg 'https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/media/1037543291543636?download=1' \
  -H 'Authorization: Bearer sk_…'
```

## Webhooks

Subscribe an endpoint (`POST /webhooks`) to the events you need; deliveries are signed and retried like every Postgale
webhook (see [Webhooks](https://staging.postgale.com/docs/api/webhooks) in the REST reference).

- `message.received`: a person wrote (text, media, interactive and button replies, location, contacts, orders); `message.reaction` for reactions; `message.sent` for messages sent through Postgale.
- `message.status`: `sent`, `delivered`, `read` or `failed` (with Meta's `errors`) for each message the account sent, with `pricing`.
- `message.failed` (a message the account sent failed, with `error`), `message.edited` (the person edited a message: the new `text` and `content`, with `editHistory`) and `message.deleted` (the person deleted a message, or the business deleted its own; `direction` tells which).
- `template.status` (approved, rejected, paused, disabled…), `template.quality` (quality score or category changes) and `account.quality` (the number's quality rating or messaging limit changed).

message.received:
```json
{
  "id": "cmdlw1a2b0001wa0000001",
  "event": "message.received",
  "createdAt": "2026-10-18T09:15:02.481Z",
  "data": {
    "eventId": "cmdlw19zz0000wa0000001",
    "platform": "whatsapp",
    "accountId": "acc_whatsapp",
    "externalId": "cust-1",
    "conversationId": "cmdlw0k700003wa0000001",
    "messageId": "cmdlw1a0q0002wa0000001",
    "platformMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y",
    "platformMessageIds": [
      "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y"
    ],
    "direction": "inbound",
    "participant": {
      "id": "15551234567",
      "username": null,
      "name": "Sheena"
    },
    "text": "Do you have the blue one in stock?",
    "attachments": [],
    "postback": null,
    "reaction": null,
    "watermark": null,
    "sentAt": "2026-10-18T09:15:00.000Z",
    "receivedAt": "2026-10-18T09:15:02.470Z",
    "type": "text",
    "content": {
      "body": "Do you have the blue one in stock?"
    },
    "contact": {
      "phone": "15551234567",
      "userId": "US.13491208655302741918",
      "parentUserId": null,
      "name": "Sheena",
      "username": null
    }
  }
}
```

message.status:
```json
{
  "id": "cmdlw2c3d0001wa0000002",
  "event": "message.status",
  "createdAt": "2026-10-18T09:16:04.112Z",
  "data": {
    "eventId": "cmdlw2c2z0000wa0000002",
    "platform": "whatsapp",
    "accountId": "acc_whatsapp",
    "externalId": "cust-1",
    "conversationId": "cmdlw0k700003wa0000001",
    "messageId": "cmdlw1x9q0004wa0000001",
    "platformMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5QTNDQTVCM0Q",
    "status": "read",
    "errors": [],
    "pricing": {
      "billable": false,
      "pricing_model": "PMP",
      "category": "service",
      "type": "free_customer_service"
    },
    "conversation": null,
    "recipient": {
      "phone": "15551234567",
      "userId": null
    },
    "timestamp": "2026-10-18T09:16:03.000Z",
    "receivedAt": "2026-10-18T09:16:04.100Z"
  }
}
```

template.status:
```json
{
  "id": "cmdlw3e4f0001wa0000003",
  "event": "template.status",
  "createdAt": "2026-10-18T10:02:11.020Z",
  "data": {
    "eventId": "cmdlw3e3z0000wa0000003",
    "platform": "whatsapp",
    "accountId": "acc_whatsapp",
    "externalId": "cust-1",
    "wabaId": "102290129340398",
    "kind": "status",
    "templateId": "1689556908129832",
    "name": "order_shipped",
    "language": "en_US",
    "event": "APPROVED",
    "reason": "NONE",
    "otherInfo": null,
    "receivedAt": "2026-10-18T10:02:11.000Z"
  }
}
```

## Contacts and broadcasts

A broadcast sends one approved template to many people, now or at a scheduled time, and tracks delivery per recipient.
The flow is: create a draft, add recipients, then send or schedule. Recipients are contacts with a channel on the account
(the phone); phones you add are turned into contacts automatically.

- `POST /contacts`, `POST /contacts/bulk` (≤1000, duplicates skipped with their tags merged), `GET /contacts` (`?search&tag&tags&accountId&isSubscribed&limit&skip`), `GET|PATCH|DELETE /contacts/{contactId}`, `GET /contacts/{contactId}/channels`.
- `POST /broadcasts` with `accountId`, `name` and `template {name, language, components?, variableMapping?}`. `variableMapping` fills `{{1}}`, `{{2}}`… per recipient from a contact field (`name`, `phone`, `email`, `company`) or a fixed `custom` value.
- `POST /broadcasts/{id}/recipients` with `phones`, `contactIds`, `recipients` (`[{phone, components}]` for per-recipient components) or `useSegment: true` (the broadcast's `segmentFilters`: `tags`, `isSubscribed`, `customFields`). Unsubscribed or blocked contacts and duplicates are skipped.
- `POST /broadcasts/{id}/send` sends now: the first batch in the request, the rest by the worker. `POST /broadcasts/{id}/schedule` with `scheduledAt` sends it then. `POST /broadcasts/{id}/cancel` stops a scheduled or sending broadcast.
- `GET /broadcasts`, `GET /broadcasts/{id}` (counts), `GET /broadcasts/{id}/recipients` (`?status`, with `errorCode`, `errorExplanation` and a `summary`), `PATCH` and `DELETE` for drafts.

POST /api/v1/broadcasts:
```bash
curl -X POST https://staging.postgale.com/api/v1/broadcasts \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '"{ accountId: 'acc_whatsapp', platform: 'whatsapp', name: 'Order confirmations', template: { name: 'order_confirmation', language: 'en', components: [{ type: 'body', parameters: [{ type: 'text', text: '{{1}}' }, { type: 'text', text: '{{2}}' }] }], variableMapping: { 1: { field: 'name' }, 2: { field: 'custom', customValue: 'VIP-2027' } } } }"'
```

Meta's messaging-limit tier caps how many different people a number may start conversations with in 24 hours
(`TIER_250` for a new number). Postgale counts the people the number already reached with templates in the last 24 hours
and stops there: the remaining recipients stay `pending`, `lastError` says why, and the worker sends them as capacity frees
up. Sending or scheduling a broadcast reaches real people, so it is queued for a person while approval applies.

## Flows

[WhatsApp Flows](https://developers.facebook.com/docs/whatsapp/flows) are forms inside WhatsApp. Create a DRAFT flow, upload its
Flow JSON, publish it, then send it.

- `GET|POST /whatsapp/flows` (`cloneFlowId` copies a flow; with `asVersion: true` the clone is the next version of that flow's lineage), `GET|PATCH|DELETE /whatsapp/flows/{flowId}`.
- `PUT /whatsapp/flows/{flowId}/json` with `flow_json` answers Meta's `validation_errors`; `GET` returns the asset with a download URL.
- `POST /whatsapp/flows/{flowId}/publish` and `/deprecate` (both irreversible), `GET /whatsapp/flows/{flowId}/preview`, `GET /whatsapp/flows/{flowId}/versions`.
- `POST /whatsapp/flows/send` with `to`, `flow_id`, `flow_cta`, `body` (and `flow_action`, `flow_action_payload`, `draft`): a live message, within the 24-hour window. `flow_token` defaults to `<flowId>:<uuid>`.
- `GET /whatsapp/flow-responses?flowId`: what people submitted (the `nfm_reply` message), with the fields parsed into `data`.
- `GET|POST /whatsapp/flows/encryption-key`: the RSA public key for flows with a data exchange endpoint.

## Groups

`GET|POST /whatsapp/wa-groups` (create returns `groupId` and `inviteLink`), `GET|POST|DELETE /whatsapp/wa-groups/{groupId}`,
`POST .../invite-link` (a new link revokes the old one), `POST|DELETE .../participants` (`phoneNumbers`; a group holds at
most 8 participants) and `GET|POST|DELETE .../join-requests` for groups with `joinApprovalMode: approval_required`.
Messages to and from a group arrive in the inbox like any conversation.

## Business account

- **Profile**: `GET|POST /whatsapp/business-profile` (about ≤139, address, description ≤512, email, websites ≤2, vertical) and `POST /whatsapp/business-profile/photo` with a public `url` or a Postgale `mediaId` (JPEG or PNG, ≤5 MB).
- **Username**: `GET|POST|DELETE /whatsapp/business-profile/username` (`transferAction: force_transfer` takes it from another number) and `GET .../username/suggestions`.
- **Display name**: `GET|POST /whatsapp/business-profile/display-name`; Meta reviews the change and the current name stays visible until then.
- **Blocked users**: `GET|POST|DELETE /whatsapp/block-users` (`users`: phones or wa_ids; per-user results) and `GET /whatsapp/block-users/status?user`. Blocking also marks the contact `isBlocked`.
- **Ice breakers and commands**: `GET|POST|DELETE /whatsapp/conversational-automation` (`prompts` up to 3, `commands` up to 30, `enable_welcome_message`).
- **Number**: `GET /whatsapp/number-info` (live from Meta), `POST /accounts/{id}/whatsapp/register`, `/request-code` and `/verify-code`.
- **Insights**: `GET /whatsapp/pricing-analytics` (`start`, `end`, `granularity`, `dimensions`) and `GET /whatsapp/account-events` (template reviews, quality and limit changes, account alerts).

These writes go to Meta as the account and are refused (`approval_required`) for API keys while approval applies.

## Templates by name and the template library

Besides the per-account routes above, templates can be handled the way Zernio does: `GET|POST /whatsapp/templates?accountId`
(exact `name` and `language` filters), `GET|PATCH|DELETE /whatsapp/templates/{templateName}` (`language` picks one variant;
`DELETE` without it removes every language), `GET|PATCH|DELETE /whatsapp/templates/id/{templateId}`. `PATCH` changes
`components` and/or `message_send_ttl_seconds` and sends the template back to review. `GET /whatsapp/template-library?name`
reads a pre-approved Meta library template and `POST /whatsapp/templates` with `library_template_name` creates it, approved at once.

## Calling

WhatsApp Business Calling lets people call the number and lets the business call them back with their permission.

- `POST /phone-numbers/{id}/whatsapp/calling` (`id`: a bought number or the WhatsApp account id) with `forwardTo` (`tel:+E164`, `sip:user@host` or `wss://host`), `recordingEnabled`, SIP credentials (stored encrypted) and `maxCallDurationSeconds`; `PATCH` updates it, `DELETE` turns calling off, `GET /whatsapp/calling?accountId` reads it.
- `GET /whatsapp/call-permissions?to` says whether the person allows calls; `POST /whatsapp/calls` with `action: send_call_permission_request` asks them.
- `POST /whatsapp/calls` with `to` places the call (queued for a person while approval applies). `GET /whatsapp/calls`, `GET /whatsapp/calls/{id}`, `GET /whatsapp/calls/{id}/recording` and `GET /whatsapp/calls/estimate?to&minutes` (zero markup; the Meta part is billed to your WABA).

## Commerce and Click-to-WhatsApp conversions

- `GET|PUT /whatsapp/commerce-settings` (`isCatalogVisible`, `isCartEnabled`) and `GET|POST|DELETE /whatsapp/catalogs` (`catalogId`; `catalogAccountId` uses another connected account's token).
- `POST /whatsapp/conversions` reports an event (`LeadSubmitted`, `Purchase`…) for a conversation that started from a Click-to-WhatsApp ad, so Meta attributes it to the ad. Email and `externalId` are SHA-256 hashed before they leave Postgale. `GET /whatsapp/conversions` lists what was sent; `GET|POST /whatsapp/dataset` reads or creates the dataset (created automatically on the first event).

## Sandbox

`POST /whatsapp/sandbox/sessions` with a `phone` sends an activation template from Postgale's shared sandbox number; the
session turns `active` once the phone replies. `GET` lists the sessions and the sandbox number, `DELETE .../{sessionId}` revokes one.

## Buy a WhatsApp number

When the Postgale instance sells numbers, an organization that has already connected a WhatsApp Business Account can get a new
number without owning a SIM: Postgale buys a US number from its carrier, adds it to that WABA with your display name, asks Meta for
the verification code by **voice call** (SMS after 10 minutes), answers the call, transcribes the code, verifies and
registers the number and connects it as a new WhatsApp account (`account.connected` and
`phone_number.provisioned`). Otherwise these endpoints answer `403 numbers_disabled`.

GET /api/v1/whatsapp/numbers/available:
```bash
curl https://staging.postgale.com/api/v1/whatsapp/numbers/available?country=US&areaCode=415&limit=5 \
  -H 'Authorization: Bearer sk_…'
```

POST /api/v1/whatsapp/numbers:
```bash
curl -X POST https://staging.postgale.com/api/v1/whatsapp/numbers \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"phoneNumber":"+14155550123","accountId":"acc_whatsapp","verifiedName":"Lucky Shrub"}'

# {"number":{"id":"…","status":"code_requested","phoneNumber":"+14155550123","codeMethod":"VOICE",…}}
```

- `status` moves through `purchasing`, `purchased`, `adding_to_waba`, `code_requested`, `verifying`, `registering` to `active` (the new `accountId`). Poll `GET /whatsapp/numbers/{id}` or listen to the webhooks.
- A failed step sets `status: failed` with `error` (e.g. `otp_timeout`, `add_failed: …`) and emits `phone_number.failed`; `POST /whatsapp/numbers/{id}/retry` continues from that step.
- Each organization may hold a limited number of numbers (`403 number_limit_reached`). The monthly price is shown by the search.
- A number with no production message (sent or received) for 30 days is released: `phone_number.release_scheduled` 3 days before, then `phone_number.released`, and its account becomes `needs_reconnect`. `DELETE /whatsapp/numbers/{id}` releases it at once.
- If Meta does not let Postgale add the number to your WABA, connect it yourself with Embedded Signup: type the number, choose **voice call**, and read the code from `lastCode` in `GET /whatsapp/numbers/{id}` (kept for 10 minutes).

MCP: `whatsapp_search_numbers`, `whatsapp_buy_number`, `whatsapp_list_numbers`, `whatsapp_get_number`, `whatsapp_release_number`.

## Errors

Errors use the usual `{"error":{"message":"…","code":"…"}}` shape; Meta's Cloud API codes are mapped as follows.

| Status | Code | Meaning |
| --- | --- | --- |
| 409 | `outside_messaging_window` | A free-form message more than 24 hours after the person's last message (or before they ever wrote). Send a template. Checked before calling Meta, and Meta's 131047 maps to it too |
| 409 | `recipient_unavailable` | The number is not on WhatsApp, uses an old app version, or is the business number itself (131026, 131021) |
| 409 | `phone_number_not_registered` | The number is not registered for the Cloud API (133010): POST /whatsapp/accounts/\{id\}/register |
| 409 | `reconnect_required` | The token is no longer valid (190): reconnect the number |
| 403 | `recipient_not_allowed` | Test numbers only message the up to 5 recipients in their allowed list (131030; WhatsApp Manager → API Setup) |
| 403 | `account_restricted` | The WhatsApp Business Account is restricted or has a payment issue (131031, 368, 131042) |
| 403 | `missing_permission` | The token lacks whatsapp_business_messaging or whatsapp_business_management; reconnect |
| 400 | `template_error` | Template name, language, parameters or status rejected (132000–132016) |
| 400 | `unsupported_message_type` | WhatsApp does not support this message type (131051) |
| 400 | `invalid_token` | Manual connect: Meta refused the access token |
| 400 | `not_supported` | Not a WhatsApp account, or a Messenger-only option (tag) on WhatsApp |
| 429 | `rate_limited` | Too many messages to this person or for the number's throughput (131056, 130429, 131048, 131057) |
| 429 | `marketing_limit` | Meta limits how many marketing templates one person receives (131049); retry later |
| 502 | `platform_error` | Any other Meta failure |

## Mock mode

Without `PROVIDER_LIVE_WHATSAPP=1` (and the Meta app credentials) WhatsApp runs in mock mode: the connect link comes back
immediately with a mock number (`+1 555-010-0000`, quality GREEN), sends return `wamid.mock…` ids, templates
live in memory (a `hello_world` template is approved) and media downloads return a small PNG. The 24-hour window,
approval gate, storage and webhooks behave exactly as in live mode, so integrations can be built and tested without Meta.
Profiles, usernames, blocked users, groups, Flows, calling settings, catalogs and datasets also live in memory, and calls placed
in mock mode end at once (42 seconds, with a recording when it is on). Live outbound calls need a SIP media bridge that this
instance does not run yet (`not_supported`).
