> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp

> Cloud API messages, templates, media, phone numbers and accounts, and webhooks.

Stateful WhatsApp Cloud API emulation on the Graph API: sending text, template, media, interactive, reaction, location, and contacts messages; mark as read with the typing indicator; the 24 hour customer service window; message templates with review and status changes; media upload with short lived download URLs; phone numbers and business accounts; webhook subscriptions with Meta's verification handshake; and webhooks for inbound messages, message statuses, and template changes, signed with `X-Hub-Signature-256`. Simulate endpoints act as WhatsApp users and as WhatsApp itself, so an app gets the inbound messages, read receipts, and failures it has to handle. Meta ships no official Node SDK, so the tests drive the emulator over raw HTTP; any HTTP client or community SDK works with its Graph base URL changed.

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/whatsapp` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `whatsapp` |
| HTTP operations | 23, browsable at `http://localhost:3999/docs/whatsapp` while it runs ([API reference](/api-reference.md)) |
| Vendor spec | [facebook/openapi](https://github.com/facebook/openapi) at `e96a1c9` |

```bash CLI theme={null}
npx @deploybaseline/cli start --service whatsapp
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const whatsapp = await createEmulator({ service: "whatsapp" });
// whatsapp.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "whatsapp": {
    "app": {
      "id": "1000000000000001",
      "secret": "baseline-whatsapp-app-secret",
      "webhook": {
        "callback_url": "http://localhost:3000/api/whatsapp/webhook",
        "verify_token": "baseline-verify-token",
        "fields": ["messages", "message_template_status_update"]
      }
    },
    "access_tokens": [
      {
        "token": "baseline-whatsapp-token",
        "name": "Baseline System User"
      }
    ],
    "business_accounts": [
      {
        "id": "102290129340398",
        "name": "Baseline Business",
        "phone_numbers": [
          {
            "id": "106540352242922",
            "display_phone_number": "15550783881",
            "verified_name": "Baseline"
          }
        ],
        "templates": [
          {
            "name": "order_update",
            "language": "en_US",
            "category": "UTILITY",
            "components": [
              {
                "type": "BODY",
                "text": "Hi {{1}}, your order {{2}} has shipped."
              },
              {
                "type": "BUTTONS",
                "buttons": [
                  {
                    "type": "QUICK_REPLY",
                    "text": "Track order"
                  }
                ]
              }
            ]
          }
        ]
      }
    ],
    "contacts": [
      {
        "wa_id": "16505551234",
        "name": "Kerry Fisher"
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service whatsapp
```

WhatsApp starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`.

### Default fixtures

With no seed config the service starts with:

* App `1000000000000001` with app secret `baseline-whatsapp-app-secret`, which signs webhooks. No callback URL, so no webhooks until one is set
* System user access token `baseline-whatsapp-token`, allowed on every business account
* WhatsApp Business Account `102290129340398` ("Baseline Business"), with the app subscribed
* Phone number id `106540352242922`, display number `+1 555-078-3881`, verified name "Baseline"
* Approved `en_US` templates `hello_world` (Meta's sample, no variables) and `order_update` (`Hi {{1}}, your order {{2}} has shipped.` with the quick replies "Track order" and "Talk to us")

Call the Graph API over any HTTP client with the base URL changed:

```ts theme={null}
const base = `${process.env.WHATSAPP_EMULATOR_URL}/v24.0`;

const response = await fetch(`${base}/106540352242922/messages`, {
  method: "POST",
  headers: { Authorization: "Bearer baseline-whatsapp-token", "Content-Type": "application/json" },
  body: JSON.stringify({
    messaging_product: "whatsapp",
    to: "16505551234",
    type: "template",
    template: {
      name: "order_update",
      language: { code: "en_US" },
      components: [
        { type: "body", parameters: [{ type: "text", text: "Ada" }, { type: "text", text: "#1042" }] },
      ],
    },
  }),
});
const { messages } = await response.json(); // [{ id: "wamid...", message_status: "accepted" }]
```

## URL mapping

| Real WhatsApp URL | Emulator URL |
| - | - |
| `https://graph.facebook.com/v24.0/...` | `$WHATSAPP_EMULATOR_URL/v24.0/...` |
| `https://lookaside.fbsbx.com/whatsapp_business/attachments/...` (media download) | `$WHATSAPP_EMULATOR_URL/_whatsapp/media/...` (from `GET /v24.0/{media-id}`) |

Graph versions `v21.0` through `v26.0` are served. Older versions are answered as v21.0 and say so in a `facebook-api-version` response header, as Graph does after a version retires; versions that do not exist yet get the unknown path error.

## Auth

* `Authorization: Bearer <token>`, or `access_token` in the query string or the body
* Tokens seeded under `access_tokens` are system users. With `business_accounts` listed, a token sees only those accounts and their numbers, templates, and media; anything else answers as if it did not exist (code `100`, subcode `33`)
* Any other token the CLI accepts acts as a system user on every account
* The app token `{app-id}|{app-secret}` is needed for `/{app-id}/subscriptions`; a user token there gets code `15`
* No token: HTTP 400 code `104`. An unknown token: HTTP 401 code `190` with a `WWW-Authenticate` header

## Errors

Errors use Graph's body:

```json theme={null}
{
  "error": {
    "message": "(#132001) Template name does not exist in the translation",
    "type": "OAuthException",
    "code": 132001,
    "error_data": { "messaging_product": "whatsapp", "details": "template name (order_updates) does not exist in en_US" },
    "fbtrace_id": "A1b2C3d4E5f6G7h8I9j0K1l"
  }
}
```

| Code | When |
| - | - |
| `104` | No access token (HTTP 400) |
| `190` | Invalid access token (HTTP 401) |
| `100` subcode `33` | Unknown id, or one the token cannot see |
| `100` | Invalid parameter; template review failures carry Meta's subcodes |
| `2500` | Unknown path |
| `15` | App token required |
| `2200` | Callback verification failed |
| `131008`, `131009` | Missing or invalid parameter in a message |
| `131021` | Sender and recipient are the same number |
| `132000`, `132001`, `132012`, `132015`, `132016`, `132018` | Template send problems |
| `131053` | Media upload problem |
| `131047`, `131026` | Asynchronous: in `failed` status webhooks for messages outside the window and undeliverable contacts |

The emulator never rate limits. Inject throttling errors such as `130429` or `131056` with [fault injection](/configuration.md#fault-injection) to test retries.

## Messages

`POST /{phone-number-id}/messages` with `messaging_product: "whatsapp"`, `recipient_type: "individual"` (optional), and `to`:

| `type` | Body |
| - | - |
| `text` | `{ body, preview_url }`, up to 4096 characters |
| `image`, `video`, `audio`, `document`, `sticker` | `{ id }` of an uploaded media object or `{ link }`, with `caption` and `filename` where WhatsApp allows them |
| `template` | `{ name, language: { code }, components }`, checked against the account's templates |
| `interactive` | `button` (up to three reply buttons), `list` (up to ten rows), `cta_url`, `location_request_message`, `flow`, and product messages (accepted as sent) |
| `reaction` | `{ message_id, emoji }`; an empty emoji removes the reaction |
| `location` | `{ latitude, longitude, name, address }` |
| `contacts` | an array of contact cards |

`context.message_id` makes a reply; `biz_opaque_callback_data` comes back on the message's status webhooks. The answer is `{ messaging_product, contacts: [{ input, wa_id }], messages: [{ id }] }`, and template sends add `message_status: "accepted"`. Sending to the business's own number is `131021`.

Templates are matched by name and language. A missing pair is HTTP 404 code `132001`; a paused template `132015`; a disabled one `132016`; wrong parameter counts `132000`; a parameter that breaks the template's rules `132012` or `132018`. Named templates (`parameter_format: NAMED`) need `parameter_name` on each body parameter. Header media, URL button suffixes, and quick reply payloads are checked against the template's components.

Mark an inbound message read, optionally with a typing indicator that lasts until the next reply:

```json theme={null}
{ "messaging_product": "whatsapp", "status": "read", "message_id": "wamid...", "typing_indicator": { "type": "text" } }
```

### Customer service window

A user's message opens a 24 hour window between that user and the business number. Inside it any message type may be sent. Outside it, a free form message is still accepted with HTTP 200, and then its status webhook arrives as `failed` with error `131047` ("Re-engagement message"), which is how WhatsApp reports it. Templates can be sent at any time. A contact seeded with `undeliverable: true` fails with `131026`.

Every other outbound message gets a `sent` and then a `delivered` status webhook, in that order. Read, played, and failed statuses come from the simulate endpoint. Status payloads follow the subscription's webhook version: from `v24.0` (the default) pricing comes only with `delivered` and there is no `conversation` object; earlier versions get `conversation` and `pricing` on `sent` and `delivered`. Service messages, and utility templates inside an open window, are priced as `free_customer_service`.

## Templates

* `GET /{waba-id}/message_templates` — List templates: filters by `name`, `status`, `category`, `language`, and `content`, selects with `fields`, and pages with `limit`, `after`, and `before`
* `POST /{waba-id}/message_templates` — Create template: with `name`, `language`, `category` (`MARKETING`, `UTILITY`, `AUTHENTICATION`), `components`, and `parameter_format`. Names are lower case letters, digits, and underscores; variables must be sequential (positional) or named consistently, and may not start or end the body; button and header limits follow WhatsApp's. Failures use code `100` with Meta's subcodes, such as `2388024` for a name and language that already exist, `2388042` for an invalid name, `2388299` for a variable at the start or end, and `2388019` past 250 templates. The answer is `{ id, status, category }`
* `DELETE /{waba-id}/message_templates?name=` — Delete template: deletes every language of a template, or one with `hsm_id`
* `GET /{template-id}` and `POST /{template-id}` — Get or edit template: `POST` edits `components` or `category`. Approved, rejected, and paused templates can be edited (otherwise subcode `2388039`), and edits go back through review

New and edited templates are reviewed according to `template_review`: `approve` (the default) answers `APPROVED` at once, `pending` leaves them `PENDING` until the simulate endpoint decides, and `reject` rejects them. Every change sends a `message_template_status_update` webhook.

## Media

* `POST /{phone-number-id}/media` — Upload media: multipart form with `file`, `type`, and `messaging_product`. WhatsApp's supported types and size limits apply (images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB); others fail with `131053`. The answer is `{ id }`
* `GET /{media-id}` — Get media URL: returns `{ url, mime_type, sha256, file_size, id, messaging_product }`. The URL works for five minutes and needs the same `Authorization` header, as on WhatsApp
* `DELETE /{media-id}` — Delete media. `GET` and `DELETE` both take an optional `phone_number_id` that must own the media
* Uploads expire after 30 days

Media users send arrives through webhooks with its `id`; fetch it the same way.

## Phone numbers and accounts

* `GET /{phone-number-id}` and `GET /{waba-id}/phone_numbers` — Get phone numbers: `display_phone_number`, `verified_name`, `quality_rating`, `code_verification_status`, `platform_type`, `throughput`, `messaging_limit_tier`, and more with `fields`
* `GET /{waba-id}` — Get business account: name, currency, timezone, template namespace, and review status
* `GET`/`POST`/`DELETE /{waba-id}/subscribed_apps` subscribes the app to the account's webhooks

## Webhooks

The app's subscription comes from `app.webhook` in the seed config or from `POST /{app-id}/subscriptions` with the app token and `object=whatsapp_business_account`, `callback_url`, `verify_token`, and `fields`. `GET /{app-id}/subscriptions` lists it and `DELETE` removes it.

Setting a subscription runs Meta's verification handshake:

```
GET <callback_url>?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=<verify_token>
```

The callback must answer 200 with the challenge as the body. Otherwise the call fails with code `2200` and Meta's message, and nothing is saved. A callback set in seed config is verified before the first delivery, and `POST /_whatsapp/webhooks/verify` runs the handshake on demand and returns the outcome.

Deliveries are POSTs of:

```json theme={null}
{
  "object": "whatsapp_business_account",
  "entry": [
    {
      "id": "102290129340398",
      "changes": [
        {
          "field": "messages",
          "value": {
            "messaging_product": "whatsapp",
            "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
            "contacts": [{ "profile": { "name": "Kerry Fisher" }, "wa_id": "16505551234" }],
            "messages": [{ "from": "16505551234", "id": "wamid...", "timestamp": "1760918400", "type": "text", "text": { "body": "Hi" } }]
          }
        }
      ]
    }
  ]
}
```

Each is signed with `X-Hub-Signature-256: sha256=<HMAC-SHA256 of the raw body keyed by the app secret>` and the older `X-Hub-Signature: sha1=...`:

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string | null, appSecret: string): boolean {
  const expected = `sha256=${createHmac("sha256", appSecret).update(rawBody).digest("hex")}`;
  return header !== null && header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```

Only fields the app subscribed (`messages`, `message_template_status_update`, and the others Meta lists) and accounts with the app in `subscribed_apps` get deliveries. They go out one at a time in the order they happened, so `sent` always arrives before `delivered`. A non 2xx answer or a timeout is retried after 1, 2, 4, 8, 16, 32, and 60 seconds, a compressed form of Meta's schedule. The inspector shows every attempt.

## Simulate

These endpoints need no token. Each returns what it did plus the webhook deliveries it caused. `phone_number_id` picks the business number and defaults to the first seeded one.

| Endpoint | Does |
| - | - |
| `POST /_whatsapp/simulate/message` | A user sends a message and opens the window: `text`, `image`, `video`, `audio`, `document`, `sticker` (an uploaded `id`, base64 `data`, or a generated sample), `location`, `contacts`, `reaction` (`message_id`, `emoji`), `interactive` (`button_reply` or `list_reply` to an interactive message named in `context.message_id`; ids are checked), or `button` (a template quick reply by `text` or `payload`). `from` is the user's number, `name` their profile name |
| `POST /_whatsapp/simulate/status` | `message_id` and `status`: `delivered`, `read`, `played`, or `failed` with `error: { code, title, details }` |
| `POST /_whatsapp/simulate/window` | `wa_id` with `open: true` or `false`, or an exact `last_inbound_at` (ISO date, or `null`) |
| `POST /_whatsapp/simulate/template-status` | `template_id`, or `name` and optional `language`, with `status` (`APPROVED`, `REJECTED`, `PAUSED`, `DISABLED`, and the rest) and `reason` |
| `POST /_whatsapp/webhooks/verify` | Runs the verification handshake against the current callback |
| `GET /_whatsapp/messages` | Every message, filtered by `phone_number_id`, `wa_id`, and `direction` |

```bash theme={null}
curl -X POST $WHATSAPP_EMULATOR_URL/_whatsapp/simulate/message -H "Content-Type: application/json" \
  -d '{"from": "16505551234", "name": "Kerry Fisher", "text": "Where is my order?"}'

curl -X POST $WHATSAPP_EMULATOR_URL/_whatsapp/simulate/status -H "Content-Type: application/json" \
  -d '{"message_id": "wamid...", "status": "read"}'
```

## Seed config

```json theme={null}
{
  "whatsapp": {
    "app": {
      "id": "1000000000000001",
      "name": "Acme Support",
      "secret": "baseline-whatsapp-app-secret",
      "webhook": {
        "callback_url": "http://localhost:3000/api/whatsapp/webhook",
        "verify_token": "baseline-verify-token",
        "fields": ["messages", "message_template_status_update"],
        "version": "v24.0"
      }
    },
    "access_tokens": [
      {
        "token": "baseline-whatsapp-token",
        "name": "Baseline System User"
      },
      {
        "token": "eu-only-token",
        "business_accounts": ["102290129340399"]
      }
    ],
    "business_accounts": [
      {
        "id": "102290129340398",
        "name": "Baseline Business",
        "currency": "USD",
        "subscribed": true,
        "phone_numbers": [
          {
            "id": "106540352242922",
            "display_phone_number": "15550783881",
            "verified_name": "Baseline",
            "quality_rating": "GREEN"
          }
        ],
        "templates": [
          {
            "name": "order_update",
            "language": "en_US",
            "category": "UTILITY",
            "components": [
              {
                "type": "BODY",
                "text": "Hi {{1}}, your order {{2}} has shipped.",
                "example": {
                  "body_text": [
                    ["Ada", "#1042"]
                  ]
                }
              },
              {
                "type": "BUTTONS",
                "buttons": [
                  {
                    "type": "QUICK_REPLY",
                    "text": "Track order"
                  }
                ]
              }
            ]
          },
          {
            "name": "welcome",
            "language": "en_US",
            "category": "MARKETING",
            "parameter_format": "NAMED",
            "components": [
              {
                "type": "BODY",
                "text": "Welcome to Acme, {{first_name}}. Reply to get started."
              }
            ]
          }
        ]
      }
    ],
    "contacts": [
      {
        "wa_id": "16505551234",
        "name": "Kerry Fisher",
        "window_open": true
      },
      {
        "wa_id": "16505550000",
        "name": "Gone Away",
        "undeliverable": true
      }
    ],
    "template_review": "approve"
  }
}
```

* IDs are optional; generated ones are 16 digit Graph ids. Write numeric ids as strings
* Seeded templates are checked like created ones and default to `APPROVED`; set `status` to seed others
* `subscribed: false` leaves an account without the app in `subscribed_apps`, so it gets no webhooks
* `window_open: true` opens the contact's window with every seeded number
* Any number can receive messages; `contacts` only names users and marks undeliverable ones

## Inspector

`GET /` shows conversations per business number with each thread's messages, statuses, and window state, the templates with their components and status, uploaded media, webhook deliveries with every attempt, and the app, tokens, accounts, and numbers.

## Limits

* One app per service, and no unversioned Graph paths
* No groups, usernames, per account or per number override callback URLs, business profiles, phone number registration, two step verification, or display name changes
* Media links in outbound messages are stored as sent, not downloaded
* Flow and product messages are accepted without checking a catalog or flow; the Marketing Messages API is not modeled
* No rate limits or messaging tier limits

## Conformance

| | |
| - | - |
| Routes | 28 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | fail, 8 allowed ([facebook/openapi](https://github.com/facebook/openapi) at `e96a1c9`) |
| SDKs tested | HTTP only |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.