Metadata-Version: 2.4
Name: tai42-channel-whatsapp
Version: 8.0.3
Summary: Meta WhatsApp API channel plugin for the TAI ecosystem — delivers ask questions to a human on WhatsApp and bridges the reply back.
Author-email: tai42 <oss@tai42.ai>
License-Expression: Apache-2.0
Project-URL: Homepage, https://tai42.ai
Project-URL: Repository, https://github.com/tai42ai/tai42/tree/main/plugins/channel-whatsapp
Project-URL: Issues, https://github.com/tai42ai/tai42/issues
Project-URL: Changelog, https://github.com/tai42ai/tai42/blob/main/plugins/channel-whatsapp/CHANGELOG.md
Keywords: mcp,channel,whatsapp,meta,graph-api,hitl,tai
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: tai42-contract<20,>=19.1
Requires-Dist: tai42-kit[redis]<10,>=9.0
Requires-Dist: httpx>=0.28
Requires-Dist: starlette>=0.40
Requires-Dist: pydantic>=2.12
Requires-Dist: pydantic-settings>=2.15.0
Requires-Dist: cryptography>=44
Dynamic: license-file

# tai42-channel-whatsapp

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

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:

```bash
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.

```bash
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:

```yaml
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_FLOW_PRIVATE_KEY` | for reacting forms | The business RSA-2048 **private** key (PEM, `SecretStr`) — the Flow data endpoint decrypts each reacting-form request's AES key with it. Required only to publish/serve a form that reacts while open; a static form never touches it |
| `CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY_PASSPHRASE` | no | Passphrase protecting `CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY` when the PEM is encrypted (`SecretStr`) |
| `CHANNEL_WHATSAPP_FLOW_ENDPOINT_URI` | for reacting forms | The public HTTPS URL of this plugin's Flow data endpoint (`{public base URL}/api/channels/whatsapp/flow-data`), configured on a reacting Flow at create time so Meta routes its data-exchange requests back here |
| `CHANNEL_WHATSAPP_ALLOWED_RECIPIENTS` | for cold templates | Whitelist of `wa_id`s 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_id`s: 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):

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. 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 a short
`enum` a **radio group** (a longer enum, or a per-send option list, a dropdown),
an **array of strings** a **checkbox group** (multiple choice), a `boolean` an
opt-in toggle, and an `integer`/`number` a numeric text field. A `control: chips`
choice draws a **chip grid** (`ChipsSelector`) — single-pick for a string `enum`,
multi-pick for an array — for Meta's 2–20 choices, else it **degrades** to the
default control (the radio/dropdown or checkbox group) for that shape. A
`control: textarea` string draws a **multi-line text area**, and a declared
`maxLength`/`minLength` is drawn as the field's character limit (`max-chars`/
`min-chars` on a single-line field, `max-length` on the text area) as well as
enforced at the answer door. A `string` field's
`inputMode` hint picks the text field's keyboard: `numeric` and `decimal` draw
the **numeric keypad** (it admits a decimal separator), `tel` the **phone
keypad**, `email` the **email keyboard**, and `url` **degrades to a plain text
field** (Meta has no `url` input-type); `text` (or no hint) is the plain text
field. `inputMode` only chooses the keyboard — the value is always submitted as
the typed string, never coerced. A `string` with
`format: date` that carries platform date constraints (`minDate`/`maxDate`,
`unavailableDates`, or a two-field range pairing) renders a bounded
**CalendarPicker** (an unconstrained date stays a plain date picker); a declared
date range is drawn as **one range control** when both ends sit on the same page
with the same visibility, no other field depends on either end, and the form has no
**review** page, else as **two date fields** (a documented degrade — a review page
reads each field's value back as text, which a range object cannot render) — either
way its ordering/span is enforced at
the single answer facet, so the submitted answer is always the two date values,
never a combined object. The platform-drawn texts of a send ride the sender's `wording` catalogue. The chat
button that **opens** the Flow reads `wording.form.open` (else *Fill form*; ≤20
characters), a page-less form's screen **title** reads `wording.form.title` (else
*Form*) and its terminal button reads `wording.form.submit` (else *Submit*; ≤35
characters), each page's own primary button reads its `button_label` (else
*Continue*/*Submit*; ≤35 characters), and a `select` ask's or options/sections
notification's list-opening button reads `wording.choice.label` (else *Choose an
option*; ≤20 characters) with its numbered-text fallback hint reading
`wording.choice.reply_hint`. A non-form `confirm`/`external` ask is a text link whose
line reads `wording.lines.answer_link` (its `{url}` token filled with the callback
link).
A page's **display blocks** (heading/body/
image, each static or a reaction-filled slot) render ahead of its inputs, a
**review** page shows a generic readback of the entered values, and a field with
a `visibleWhen` predicate is shown/hidden by client-side `If`s — one per value
when it is shown for several of a controlling field's values (a single `If`
condition cannot test membership), each under a distinct per-case name. 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 refusal sentence — the
sender's `wording.answer.rejected` (its `{reason}` token filled with the door's
error line, which names the failing field) when set, else a generic lead and that
line. 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 of non-strings, 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.

### Forms that react while open

A form whose ask names a reaction handler and declares reaction triggers (a
field change, a page advance, or the submission) publishes as an
**endpoint-driven Flow**: its reacting controls fire encrypted `data_exchange`
requests to this plugin's Flow **data endpoint**
(`{public base URL}/api/channels/whatsapp/flow-data`), which decrypts them,
calls the platform's one reaction chokepoint, and returns the resulting form
update (set values, replaced choice lists, per-field errors, or a computed
display slot) back to the open Flow — the plugin only translates the vendor wire
to and from that chokepoint and holds no logic of its own. A reaction that
fails or times out surfaces as a generic error on the Flow and a loud server log,
never a stale value. A **reacting form cannot publish or be served without its
key material**, so the delivery refuses loudly (it never silently falls back to a
non-reacting flow) when it is missing.

The vendor's data endpoint needs a business key pair provisioned once per
sending number (the plugin never mutates Meta configuration at startup):

1. Generate an RSA-2048 key pair (PKCS#8 PEM).
2. Set the private key as the secret `CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY` (and, if
   the PEM is encrypted, `CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY_PASSPHRASE`). It
   never leaves the environment; a fork configures its own.
3. Set `CHANNEL_WHATSAPP_FLOW_ENDPOINT_URI` to the public URL of the
   `/api/channels/whatsapp/flow-data` route.
4. Register the **public** key for the sending number — run
   `tai42_channel_whatsapp.provisioning.provision_flow_encryption()`, which
   derives the public key from the configured private key and registers it via
   the Graph API (re-run after a key rotation).
5. Ensure the endpoint is publicly reachable and healthy (Meta `ping`s it).

The data endpoint authenticates each request with `X-Hub-Signature-256` over the
raw body (the same app secret as the webhook; a mismatch is HTTP 432), decrypts
the AES-128-GCM payload with the private key (an undecryptable body is 421), and
answers an unknown/expired flow token with 427.

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` (with `caption`/`filename`), `video` (with `caption`), or `audio`
  (voice/clip; the Cloud API audio object carries no caption/filename); a `link`
  item is appended to the body text as a `label: url` line.
- **Options** (`list[Option]`, a typed union, never bare strings) — a
  `ReplyOption` renders as a native reply **button** (or a **list** row past the
  button caps / for a described option), sending its **authored `id`** on the
  wire when set (echoed back on tap) else a minted index; a tap enters the
  conversation as a visitor message. A lone `LinkOption` renders as a `cta_url`
  interactive (one URL button); mixed reply+link or multiple links append the
  link(s) to the body as `label: url` lines (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 (an `audio` header is sent as its own message ahead of it);
  a text `footer` rides 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
  `ChannelTemplate` by name: its `header_media` (image/video/document),
  `body_parameters` (positional body-text fills), and `buttons` (per-button
  quick-reply `payload` / 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 **served** media id the bytes were ingested to (see the inbound media note below) |
| `media_mime_type` | " | The ingested media's sniffed-and-agreed mime type |
| `media_sha256` | " | The ingested media's `sha256` (content integrity) |
| `media_size` | " | The ingested media's actual byte size |
| `media_filename` | An inbound **document** | The sanitised `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.** WhatsApp inbound media arrives as a Graph media **id**; the
> channel looks up the object's short-lived, Bearer-authenticated lookaside URL,
> streams the bytes, and ingests them through the platform's `app.media.ingest_media`
> chokepoint, which caps, sniffs, stores, and mints a served
> `{MEDIA_ROUTE_PREFIX}{id}` reference. The bridged turn then carries **both** a typed
> `attachments` entry (a `MediaItem` at the served URL) and the parity `media_*` params
> off that one ingest. A **transient** fetch fault (a `5xx`, a timeout, a torn read)
> raises so the webhook `5xx`s and Meta redelivers (idempotent through the per-`wamid`
> dedupe); a **permanent** fault rejects with a participant notice — `too_large` (over
> the operator's per-kind cap), `unsupported_type` (the sniffed content is not on its
> allowlist), or `could_not_receive` (the media is gone, the URL is guarded, or no store
> is configured) — and, when a caption rode with it, bridges the caption as a text-only
> turn so no message is lost. The Bearer rides the fetch calls only, never a log, param,
> or error string.

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.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` 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 |
| Form schema is a flat object subset | A `form` ask's answer schema is a top-level `object` whose properties are `string`, `string`+`enum`, an **array of strings** (a multiple-choice field), `boolean`, `integer`, or `number`. The platform enforces this subset at ask-time, so nested objects, arrays of non-strings, 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. A **reacting** form additionally reserves the property names `tai42_event`, `tai42_field`, and `tai42_page` (its data-exchange event markers) |
| 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

```bash
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.
