Skip to main content
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

Start

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:

URL mapping

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:
The emulator never rate limits. Inject throttling errors such as 130429 or 131056 with fault injection to test retries.

Messages

POST /{phone-number-id}/messages with messaging_product: "whatsapp", recipient_type: "individual" (optional), and to: 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:

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

Seed config

  • 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

See CONFORMANCE.md for every provider.