Skip to main content

tai42-channel-whatsapp

License: Apache 2.0

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:

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):

  1. In the Meta App dashboard, point the WhatsApp webhook callback URL at {public base URL}/api/channels/whatsapp/inbound and set the verify token to CHANNEL_WHATSAPP_VERIFY_TOKEN. Meta issues a GET handshake the route answers by echoing hub.challenge.
  2. Subscribe the app to the messages webhook field so message and delivery-status events reach the same POST endpoint.

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.challenge only when hub.verify_token matches the configured token under a constant-time compare.
  • Meta's signature scheme carries no timestamp, so a captured request validates forever; the wamid dedupe 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

tai42_channel_whatsapp-0.8.0.tar.gz (57.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tai42_channel_whatsapp-0.8.0-py3-none-any.whl (35.1 kB view details)

Uploaded Python 3

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

Hashes for tai42_channel_whatsapp-0.8.0.tar.gz
Algorithm Hash digest
SHA256 4d0409c1ff8d748ec0da6831891ac7c511785d77d60f3366c66d45bda615a636
MD5 5973638f7b234f4035e472fb857e8d64
BLAKE2b-256 3606269038ff4699533408ffb0eb9aefc6e0d390d9db72f1a1e77b0fb69e3a47

See more details on using hashes here.

File details

Details for the file tai42_channel_whatsapp-0.8.0-py3-none-any.whl.

File metadata

File hashes

Hashes for tai42_channel_whatsapp-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dbff0d486fed917da0506feeca81d29875db0b1753b697ec1faac8788fe3ad36
MD5 bfdd29acd426456fd51d2baa7ebc4781
BLAKE2b-256 4810ab828c6393974a85aeabe7d113db64eb25127a560515ca5b275d9f0af9e4

See more details on using hashes here.

Release history Release notifications | RSS feed

2.1.0

2 files

2.0.0

2 files

1.3.2

2 files

1.3.1

2 files

1.3.0

2 files

1.2.1

2 files

1.2.0

2 files

1.1.1

2 files

1.1.0

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

0.9.0

2 files

This release

0.8.0 This release

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.1

2 files

0.4.0

2 files

0.3.0

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page