tai42-channel-whatsapp
A Meta WhatsApp API channel plugin for the TAI ecosystem. It delivers
an ask question to a human on WhatsApp through the Cloud (Graph) API and
bridges the human's reply back into the interactions store — so an agent can
reach a person out-of-band instead of only showing the question in the Studio
inbox. It implements the tai42_contract.channels.Channel protocol and registers
under the name "whatsapp". It is for numbers hosted directly on Meta's
Cloud API (no BSP/Twilio in front); the Twilio-hosted path is the sibling
tai42-channel-twilio.
The TAI ecosystem
TAI is an open-source runtime for MCP tools, agents, and workflows. A Channel
is "how a question reaches a human" — a pluggable deliverer the runtime resolves
by name when ask is called with channel=.... This package is one such
deliverer (WhatsApp API); siblings back the same contract with Twilio,
Telegram, or Slack. The ecosystem is open-ended: any package can back the same
contract, so this repo is this plugin's own full doc home, and the documentation
site covers the platform-level story:
- Interactions concept: https://tai42.ai/concepts/interactions
- Build a channel plugin (author guide): https://tai42.ai/guides/authors/channel
- Ecosystem catalog: https://tai42.ai/reference/catalog
Its only tai-* dependencies are tai42-contract (the Channel protocol,
ChannelDelivery, ChannelDeliveryError, and the tai42_app handle) and
tai42-kit[redis] (HttpxClient, RedisClient, TaiBaseSettings, and the
settings cache). Beyond those it depends on httpx, starlette, and
pydantic / pydantic-settings. There is no Meta SDK: the send is one
Bearer-auth JSON POST over httpx, and webhook signature validation is a few
lines of stdlib hmac/hashlib.
Install
Requires Python 3.13+. Install from PyPI into the environment that runs the server:
uv add tai42-channel-whatsapp
Or from source — clone this repo and add it as an editable dependency; the
tai42-* dependencies resolve in-tree from the workspace.
git clone https://github.com/tai42ai/tai42 # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai42/plugins/channel-whatsapp
Discovery
The runtime discovers this plugin through the manifest's channel_modules key:
channel_modules: ["tai42_channel_whatsapp"]
At app load the runtime imports every module under the package, and
register.py fires the registrations as its import side-effect: the
"whatsapp" channel on tai42_app.channels, and — via the inbound
import — the unauthenticated webhook route on tai42_app.http. A bare
import tai42_channel_whatsapp registers nothing — the package is
library-safe; only the register module carries the side-effect.
Configuration
Settings are read from the CHANNEL_WHATSAPP_ environment group (see
WhatsAppSettings / WhatsAppRedisSettings):
| Env var | Required | Meaning |
|---|---|---|
CHANNEL_WHATSAPP_ACCESS_TOKEN |
yes | Graph API access token (SecretStr) — the Bearer credential for the send |
CHANNEL_WHATSAPP_APP_SECRET |
yes | Meta app secret (SecretStr) — the X-Hub-Signature-256 HMAC key for inbound webhooks |
CHANNEL_WHATSAPP_VERIFY_TOKEN |
yes | Shared token (SecretStr) echoed during Meta's GET webhook verification handshake |
CHANNEL_WHATSAPP_DEFAULT_PHONE_NUMBER_ID |
for ask | The phone_number_id messages are sent FROM when no sender identity is routed |
CHANNEL_WHATSAPP_WABA_ID |
for forms | The WhatsApp Business Account id that owns Flows — a form ask and an ask-less form notification are rendered as a WhatsApp Flow created and published under this WABA, and the notify-form schema cache is keyed under it. Required only on the form paths |
CHANNEL_WHATSAPP_ALLOWED_RECIPIENTS |
for cold templates | Whitelist of wa_ids a template send may reach when the recipient is not a known contact — comma-separated or a JSON list. Freeform sends are not fenced by it |
CHANNEL_WHATSAPP_TEMPLATE_CONTACT_WINDOW_DAYS |
no (30) | Rolling "seen within N days" window admitting a template send to a wa_id the inbound webhook has messaged from; 0 disables known-contact tracking (allowlist-only templates) |
CHANNEL_WHATSAPP_API_BASE_URL |
no | Graph API origin + pinned version (default https://graph.facebook.com/v23.0) |
CHANNEL_WHATSAPP_REDIS_URL |
yes | Correlation + known-contact store (plugin-owned Redis) |
CHANNEL_WHATSAPP_REDIS_MAX_CONNECTIONS … |
no | The rest of the kit RedisConnectionSettings fields, same names under this prefix |
CHANNEL_WHATSAPP_HTTP_TIMEOUT_SECONDS |
no (30.0) | Outbound send + answer-forward timeout, seconds |
CHANNEL_WHATSAPP_DEDUPE_TTL |
no (172800) | Seen-wamid replay-guard window, seconds |
One credential (ACCESS_TOKEN + APP_SECRET) serves many phone_number_ids: a
bridge reply is sent from the exact phone_number_id that received the inbound
message, while an ask delivery is sent from
CHANNEL_WHATSAPP_DEFAULT_PHONE_NUMBER_ID. Recipient policy is split by what
Meta itself fences. Freeform sends (questions, replies, media) reach any
requested wa_id — Meta's own 24-hour customer-service window is the fence, so a
send to a number outside it is rejected by Meta (error 131047) and raises.
Template sends are the one send Meta delivers cold, so they keep an operator
fence: the recipient must be on CHANNEL_WHATSAPP_ALLOWED_RECIPIENTS or be a
known contact — a (phone_number_id, wa_id) pair the inbound webhook has seen
within CHANNEL_WHATSAPP_TEMPLATE_CONTACT_WINDOW_DAYS days; a cold, unlisted
number raises. A recipient is always caller-supplied — there is no default
recipient, so a recipientless request raises. Secrets live only in the
environment.
Two steps happen out-of-band (the plugin never mutates Meta app configuration at startup):
- In the Meta App dashboard, point the WhatsApp webhook callback URL at
{public base URL}/api/channels/whatsapp/inboundand set the verify token toCHANNEL_WHATSAPP_VERIFY_TOKEN. Meta issues aGEThandshake the route answers by echoinghub.challenge. - Subscribe the app to the
messageswebhook field so message and delivery-status events reach the samePOSTendpoint.
How a human answers
A text question arrives as a normal WhatsApp message; the human just
replies — no code to quote, no prefix. A select question renders natively: a
few short options become tappable reply buttons, more options become an
interactive list, and past those platform caps it falls back to a numbered
plaintext list. A tap answers by a question-bound id that maps back to the exact
option text; the human may always type an option instead. A form question
renders as an in-chat WhatsApp Flow — one screen of typed fields the human
fills and submits: a string becomes a text field, a string with an enum
a dropdown, a boolean an opt-in toggle, and an integer/number a numeric
text field. The Flow is created and published once per distinct answer schema
(cached by a schema hash under CHANNEL_WHATSAPP_WABA_ID) and reused; the
completed form returns as an nfm_reply, its values coerced to the schema's
types before the answer object is forwarded. When the callback door rejects
a forwarded form answer (400 — a schema rule the Flow could not enforce, e.g. a
minimum or pattern), a completed Flow has no re-reply surface, so the channel
re-sends a fresh Flow for the same interaction: same flow token, the cached
flow id, and a body that repeats the question with the door's error line (which
names the failing field). This is bounded — after a fixed number of
rejections the channel stops re-sending, tells the participant once the form could not
be processed, and lets the ask time out on its own deadline. The platform
validates the answer schema against this subset when the question is asked —
before the question is stored — so an out-of-subset schema (nested objects,
arrays, unions, unknown types) never reaches this send; the Flow mapping rejects
one defensively too.
Correlation is fully out-of-band: any reply (typed, tapped, or
a submitted form) from the recipient resolves the (phone_number_id, wa_id)
pair's pending question, so one question can be pending per pair at a time; a
second concurrent one is rejected loudly.
An agent notification (notify_user) advertises the full capability set —
supports_media_notifications, supports_location_notifications,
supports_template_notifications, supports_interactive_notifications, and
supports_form_notifications — so a notification may carry the full outbound
vocabulary:
- Media — each file item sends as its own native message: an
image,document(withcaption/filename),video(withcaption), oraudio(voice/clip; the Cloud API audio object carries no caption/filename); alinkitem is appended to the body text as alabel: urlline. - Options (
list[Option], a typed union, never bare strings) — aReplyOptionrenders as a native reply button (or a list row past the button caps / for a described option), sending its authoredidon the wire when set (echoed back on tap) else a minted index; a tap enters the conversation as a visitor message. A loneLinkOptionrenders as acta_urlinteractive (one URL button); mixed reply+link or multiple links append the link(s) to the body aslabel: urllines (WhatsApp has no multi-URL button). - Sections (
list[OptionSection]) — a multi-section interactive list, each row carrying an optional description secondary line. - Header + footer — a media
header(image/video/document) rides the interactive header (anaudioheader is sent as its own message ahead of it); a textfooterrides the interactive footer. Both require options/sections. - Location (
LocationElement) — a native location message (a pin with an optional name/address). - Template — for a send outside the 24-hour window, a pre-approved
ChannelTemplateby name: itsheader_media(image/video/document),body_parameters(positional body-text fills), andbuttons(per-button quick-replypayload/ url suffix) map onto the template-message components. - Ask-less form (
schema) — an in-chat WhatsApp Flow (see below).
template, a choice surface, and schema are mutually exclusive on one
notification; options and sections are mutually exclusive with each other;
media and location may combine with a choice surface.
An ask-less form notification renders exactly like a form ask — any media
first, then a WhatsApp Flow whose body is the message — but stores no
correlation: the flow token is minted in the tai42-nf: namespace
(tai42-nf:{schema hash}:{random}), and the inbound webhook routes an
nfm_reply by that prefix before any pending-question lookup, so a submission
enters the conversation as a structured participant message (rendered label: value
text plus the structured copy) and can never answer — or disturb — a question
pending on the same pair. The answer schema is cached durably beside the
published-flow id under the schema hash; a reply carries only the hash, so a
lost cache entry cannot be repopulated from it — the reply then degrades to its
raw (uncoerced) values, and is still accepted. Like every freeform send, a form
notification delivers only inside Meta's 24-hour customer-service window — out
of window the send fails loudly (error 131047), synchronously or as a failed
status; it is never silently downgraded to a template.
A confirm or external question arrives as a tappable link and is answered in
the browser via the callback door — no WhatsApp reply is expected or matched, and
it never consumes the pair.
Inbound entry parameters
When an inbound message enters the conversation bridge as a fresh turn (a
message with no pending question to answer), the channel forwards a set of opaque
entry parameters alongside the turn text. They ride verbatim to a tool
target's payload under its own params key (payload["params"]) — the platform
attaches no meaning and no trust; a channel-agnostic consumer opts into
whichever keys it understands. This is the channel's public inbound contract:
params key |
Set when | Value |
|---|---|---|
reply_id |
An interactive reply button / list row is tapped but is not an answer to a pending ask (no ask, or a stale/other id) | The question-bound wire id the outbound carried (button_reply.id / list_reply.id) |
reply_description |
The tapped item is a list row that carried a secondary description line | list_reply.description |
button_payload |
A template quick-reply button is tapped (a button-type message) |
The developer-defined button.payload behind the visible button.text |
context_message_id |
The message quotes / replies to an earlier one | context.id (the quoted wamid) |
referral_source_url |
The message enters via click-to-WhatsApp / QR (referral) |
referral.source_url |
referral_source_id |
" | referral.source_id |
referral_source_type |
" | referral.source_type (e.g. ad, post) |
referral_ctwa_clid |
" | referral.ctwa_clid (the click-to-WhatsApp click id) |
referral_headline |
" (when present) | referral.headline |
referral_body |
" (when present) | referral.body |
media_kind |
An inbound media message (image/document/audio/video/sticker) | The wire type |
media_id |
" | The Graph media id (re-fetch handle — see the design note below) |
media_mime_type |
" | The media object's mime_type |
media_sha256 |
" | The media object's sha256 (content integrity) |
media_filename |
An inbound document | document.filename |
media_voice |
An inbound audio that is a voice note | "true" (audio.voice) |
sticker_animated |
An animated sticker | "true" (sticker.animated) |
reaction_emoji |
An inbound reaction (absent = a removed reaction) | reaction.emoji |
reaction_message_id |
An inbound reaction | The wamid it was applied to (reaction.message_id) |
contacts_count |
An inbound contacts message | The number of shared contact cards |
contacts |
" | The raw contacts array as compact JSON (dropped when over the per-value cap; contacts_count still rides) |
Participant media and location and reactions and contacts bridge
as turns. The caption of a media message becomes the
turn text (a faithful [image] / [document: file] / [voice message] / …
placeholder when caption-less); an inbound location lands as a typed
LocationElement on the turn's location (a machine-consumable field, not a
param), with the place name/coordinates as the text.
Inbound media design note (design gap). WhatsApp inbound media arrives as a Graph media id; resolving it to bytes is a two-step Graph call that returns a short-lived, Bearer-authenticated lookaside URL — not a durable public
httpsURL and not a validMediaItemsource. Minting a durable typedattachmentsentry needs a served-media ingestion seam (fetch → persist → mint a{MEDIA_ROUTE_PREFIX}{id}served reference); the platform's served-media store is not reachable from a channel plugin and handles only outbounddata:imagesubstitution, so no such seam exists today. Rather than invent infrastructure or fabricate an unfetchable URL, inbound media therefore bridges without a typedattachmentsentry — its identity rides themedia_*params above and a consumer re-fetches viamedia_idwith operator credentials. The durable fix is a platform served-media ingestion seam on the app handle.
Params ride only on the bridge path. A tap or quick-reply that answers a
pending question does not surface them: the answer path forwards {"answer": …}
to the callback door — a seam that carries no params — and a tap's id is already
consumed there to select the option. Values are transport-bounded (per the
platform's entry-param limits — count, key charset, value length, total size); an
individual value over the per-value cap is dropped (never truncated), and in the
rare case the aggregate still overflows a bound the whole set is dropped and the
turn bridges without it — a participant message is never lost to a params bound.
Meta inbound error notices (e.g. an unsupported message type the participant sent,
carried as an errors[] array) are logged at warning with the detail and are
never bridged as participant turns.
Delivery statuses
WhatsApp reports delivery asynchronously: a send returns 2xx with a wamid, and
a later statuses webhook carries sent/delivered/read/failed. The same
POST endpoint records those receipts through the interactions facet — failed
(e.g. a message outside the 24-hour session window, error 131047) marks the
answer failed loudly; sent/delivered confirm it; read is informational and
ignored.
A wamid the conversation bridge does not own is not a dead end: it may be a
notify_user send (which runs no conversation record). The webhook then
resolves the wamid through the send-outcome receipt index and, on a hit, posts a
delivery_receipt event onto the send's originating monitoring trace, nested under
its send span — ERROR for a failed receipt (carrying the provider errors),
informational otherwise. This is observability only: it annotates the originating
trace, never re-sending or failing the run. Only a genuine miss — neither the bridge
nor any such send owns the wamid — keeps the untracked-message log; either way the
status is acknowledged, never retried.
Security
- Inbound requests authenticate via
X-Hub-Signature-256:sha256=+ hex(HMAC-SHA256(app_secret, raw body)), validated fail-closed with a constant-time compare before the body is parsed. A missing/empty app secret is an operator error that raises loudly (logged 500) — never a soft 401 that reads like a bad signature. - The GET verification handshake echoes
hub.challengeonly whenhub.verify_tokenmatches the configured token under a constant-time compare. - Meta's signature scheme carries no timestamp, so a captured request validates
forever; the
wamiddedupe window (48h default) plus HTTPS are the replay guards. - The access token, app secret, and verify token are
SecretStr— never in a repr, log line, or traceback; the plaintext is read only at the Bearer-auth and HMAC seams. - The unauthenticated route bounds its body read (1 MiB → 413) before any signature work.
Limits
| Limit | Consequence |
|---|---|
One pending question per (phone_number_id, wa_id) pair |
A second concurrent ask over this channel fails loudly with PendingQuestionExistsError while the first is unanswered/unexpired |
| Freeform sends need the 24h window | A freeform send (question, reply, media) outside the human's 24-hour session window is rejected by Meta (error 131047), synchronously as a delivery error or asynchronously as a failed status. A template is the only send Meta accepts outside the window |
| Single send attempt per part | A transient Cloud API outage fails the send instead of retrying (no idempotency key → a blind retry risks double-messaging). A multi-part media send that fails on the Nth part raises naming the wamids already delivered |
| Inbound media carries no typed attachment | Inbound media (image/document/audio/video/sticker) bridges as a turn (caption → text, identity → media_* params) but without a typed attachments entry: the Graph media id is not a durable MediaItem source and no served-media ingestion seam is reachable from a channel (see the inbound media design note). A consumer re-fetches bytes via media_id + operator credentials. Inbound location DOES land a typed LocationElement; contacts/reactions ride params |
| Form schema is a flat object subset | A form ask's answer schema is a top-level object whose properties are string, string+enum, boolean, integer, or number. The platform enforces this subset at ask-time, so nested objects, arrays, and oneOf/anyOf are refused before the question is stored; the Flow mapping refuses them defensively too, and additionally rejects a property named flow_token — Meta reserves that key on the Flow response, so a field of that name is unanswerable on this channel |
| Template audio header unsupported | A ChannelTemplate maps header_media (image/video/document), body_parameters, and quick-reply/url buttons onto the template-message components. An audio header_media has no Cloud API template representation and is refused loudly (ChannelInputError); an audio interactive header on a notification is instead sent as its own message ahead of the interactive |
| No timestamp in Meta's signature scheme | Replay of a captured request validates forever for that body; wamid dedupe (48h default window) + HTTPS are the guards (a Meta protocol property) |
Development
uv venv --python 3.13
uv pip install --no-sources --group dev --editable .
uv run --no-sync pytest --cov --cov-report=term-missing
uv run --no-sync ruff check .
uv run --no-sync ruff format --check .
uv run --no-sync pyright
The live integration suite (pytest -m integration) sends real messages via the
WhatsApp API and runs only when the CHANNEL_WHATSAPP_* credentials
are present in the environment; it skips cleanly otherwise.
License
Apache-2.0. See LICENSE and NOTICE.
Correlation surface (2.0)
Since 2.0 inbound answers resolve through the platform's shared inbound-answer
ladder: the plugin exposes its correlation store over the contract's
CorrelationStore port (reserve / peek / release) plus a transport ack, and the
skeleton owns the forward / retry-in-place / bridge ladder. The plugin-local
pop/restore correlation helpers from 1.x are gone.
Release files for tai42-channel-whatsapp 5.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tai42_channel_whatsapp-5.0.1.tar.gz | 135.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_whatsapp-5.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 224.2 kB
Release files / tai42_channel_whatsapp-5.0.1.tar.gz
| Download URL | tai42_channel_whatsapp-5.0.1.tar.gz |
|---|---|
| Size | 135.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
84a820a0c3e6bb7713cdd60a0e026ab888950150fd3ae741a0ca3a94b7049445
|
|
BLAKE2b-256 checksum How to use checksums |
0c6d101fa3ac16332eefe2d6b5020e99ffe690e7b753662406c15ff6ee19fbef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / tai42_channel_whatsapp-5.0.1-py3-none-any.whl
| Download URL | tai42_channel_whatsapp-5.0.1-py3-none-any.whl |
|---|---|
| Size | 89.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ed0a39694ca0c9b3e201f8136a39f57256574e077ea3a3373d56b8b4e9e288fe
|
|
BLAKE2b-256 checksum How to use checksums |
248a20b82fa1521a4a82bf8e6d5589af3167dce15424fc5772ba4f146530effc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|