verifyChallenge(),
POST events with ingest() (see the server example
in the channels overview).
Install
Usage
Setup in Meta: create an app with the WhatsApp product on developers.facebook.com, generate a permanent system-user token withwhatsapp_business_messaging, copy the sender’s phone number id, then set
the webhook URL and a verify token of your choice under WhatsApp →
Configuration and subscribe it to the messages field (it carries both
messages and delivery statuses).
- Native reply buttons (max 3: more rejects with
UnsupportedContentError, send a list instead), lists, templates and WhatsApp Flows forms. A button withurlgoes out as acta_urllink button (one per message: several links, or a link mixed with reply buttons, reject). Acardkeeps its image (as the media or the interactive header) and its buttons. Graph’s limits are checked before sending: no buttons, repeated button ids or titles (compared after the 20-character cut), a list with no rows, more than 10 rows in total or repeated row ids reject withUnsupportedContentError. Labels are cut on grapheme boundaries, so an emoji is kept whole or dropped, never split. - A missing
accessToken,phoneNumberIdorverifyTokenfails at construction with an error naming the field (phoneNumberIdmay also be a safe integer). Every Graph request has a timeout (requestTimeoutMs, 60 s by default); media uploads have their own (uploadTimeoutMs, 5 min by default). - An interactive reply that is not a button or list press (for example
call_permission_reply) arrives as anunknownmessage with the raw payload inctx.ref.native. - Graph API version:
apiVersiondefaults toDEFAULT_GRAPH_API_VERSIONfrom@botmesh/graph-core(v26.0, released 2026-07-29; Messenger and Instagram use the same default). Meta retires each version about two years after its release, so the default follows the current one and moves in a minor release, after the payloads are checked against Meta’s changelog. PinapiVersion(for example'v24.0') to stay on a version until you migrate. Aplayedstatus (a voice note was listened to) is reported asreadwithplatformData.played: true. - Users with a WhatsApp username may arrive with a BSUID instead of a phone
number; the provider keeps one stable id per person (
identityMap). - Outside the 24 h window free-form sends reject with
OutsideSessionWindowError. The optionalsessionWindowmanager reopens the chat with a template and retries: see 24-hour window and pricing. - No groups, typing or polls in the API, and no delete for everyone: the
provider has no
typing()ordeleteMessage(), soctx.typing()andctx.delete()do nothing (see the cross-channel contract). - Text over 4096 characters is split into several messages by the core.
- While the bot is stopped,
ingest()answers503, so Meta redelivers the webhook after the restart. publishForm()bakes the default submit label of a form into the Flow in the provider’slang(default'en'; set it to your bot’sBotConfig.lang), or inpublishForm(def, { lang })for one Flow. A form’s ownsubmitLabelalways wins.
Next steps
- Channels: choosing a channel, the capability matrix and the cross-channel contract.
- 24-hour window and pricing: reopen a chat with a template and track usage.
- Forms: native WhatsApp Flows.
- Webhook server: mount the provider with
@botmesh/server. - Package README