tai42-channel-whatsapp
A Meta WhatsApp API channel plugin for the TAI ecosystem. It delivers
an ask_user 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_user 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_user | The phone_number_id messages are sent FROM when no sender identity is routed |
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_user 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. Correlation is
fully out-of-band: any reply (typed or tapped) 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) may also carry media — images
sent as image messages, links appended to the message text — or, for a send
outside the 24-hour window, a pre-approved template referenced by name. Media
and template are mutually exclusive on one notification.
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.
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 status for a message the bridge does not track 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_user 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 |
| Guest-sent media stays inbound-dropped | Inbound non-text, non-interactive messages (image, audio, location, …) are acknowledged and debug-logged, not bridged. Outbound supports text, interactive buttons/list, image, and template |
| Template scope is body-text | A ChannelTemplate carries positional body-text parameters only — header media, button, and typed (currency, date-time) parameters are out of scope |
| 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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tai42_channel_whatsapp-0.8.0.tar.gz.
File metadata
- Download URL: tai42_channel_whatsapp-0.8.0.tar.gz
- Upload date:
- Size: 57.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4d0409c1ff8d748ec0da6831891ac7c511785d77d60f3366c66d45bda615a636
|
|
| MD5 |
5973638f7b234f4035e472fb857e8d64
|
|
| BLAKE2b-256 |
3606269038ff4699533408ffb0eb9aefc6e0d390d9db72f1a1e77b0fb69e3a47
|
File details
Details for the file tai42_channel_whatsapp-0.8.0-py3-none-any.whl.
File metadata
- Download URL: tai42_channel_whatsapp-0.8.0-py3-none-any.whl
- Upload date:
- Size: 35.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dbff0d486fed917da0506feeca81d29875db0b1753b697ec1faac8788fe3ad36
|
|
| MD5 |
bfdd29acd426456fd51d2baa7ebc4781
|
|
| BLAKE2b-256 |
4810ab828c6393974a85aeabe7d113db64eb25127a560515ca5b275d9f0af9e4
|