Channel plugin · listing tai42/channel-whatsappInstall
Permissions
Provides
Channel — Deliver ask questions to a human on WhatsApp via the Meta Cloud API and bridge the reply back.
Bridge a WhatsApp number carried by the Meta Cloud API to an agent, or deliver
ask questions into it. Unlike Twilio, this channel sends interactive
controls, media, and pre-approved templates.
Enable it
manifest.yml
Configuration
TheCHANNEL_WHATSAPP_ env group. CHANNEL_WHATSAPP_REDIS_URL (and the
inherited connection-tuning fields) point the plugin-owned correlation store.
Point the webhook at the deployment
Meta uses a single webhook endpoint for verification, inbound messages, and delivery statuses (configured in the Meta App dashboard):- Callback URL →
{public base URL}/api/channels/whatsapp/inbound - Verify token → the value of
CHANNEL_WHATSAPP_VERIFY_TOKEN
GET with hub.challenge; the door echoes it as a
text/plain body only when the verify token matches. Inbound messages and delivery statuses then arrive
as signed POSTs on the same URL. A delivery status for a bridge message
updates its answer record (a failed ends it failed); a status for a
notification send (notify_user, which runs no bridge record) is instead posted as a
delivery_receipt event onto that send’s monitoring trace — failed as an
ERROR. A status neither owns is acknowledged and ignored.
Create a bridge route
our_identity is the number’s phone_number_id (from the Meta dashboard),
not the display number:
Rich messages
- Interactive select asks — when a
selectquestion is delivered, its options render as native WhatsApp controls: a short set becomes tappable reply buttons, a longer set a list message. Past WhatsApp’s row cap, or when an option’s text is too long for a button or row, the ask falls back to the numbered-text prompt so the person always sees every option in full. Either way the answer is the option text. - Rich notifications —
notify_userrenders the full outbound vocabulary: media (image / document / video / audio each as its own native message; a link appended to the body), tappable options (ReplyOption→ reply buttons or a list, sending an authoredidon the wire when set; a loneLinkOption→ acta_urlURL button), multi-section lists with per-row descriptions (sections), a media header + text footer on an interactive message, a shared location, an ask-less form (schema, an in-chat Flow), and out-of-window templates (header_media+body_parameters+ quick-reply/urlbuttons). A choice surface,schema, andtemplateare mutually exclusive on one send; media and location may combine with a choice surface. A channel that cannot render a given field never receives it (the send gates on the matchingsupports_*capability flag).
Form asks
Aform ask (an ask whose answer is a small JSON object) renders as an in-chat
WhatsApp Flow: the participant fills the fields and submits, and the completed
values return as the answer. A multi-page layout renders one screen per page, and
per-send prefill values and option lists open the form already filled in. A short
string enum renders a radio group (longer, a dropdown), an array of strings a
checkbox group, a format: date a calendar picker (bounded by its date constraints); a page’s
display blocks and a review page render too, and a visibleWhen field is shown or
hidden client-side. A field shown for several values of its controlling field is drawn
as one control per value; such a form is endpoint-driven (below), and the data endpoint
keeps the value of the case whose condition holds under the field’s own name.
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. The value is
always submitted as the typed string. A format: email field draws the email keyboard
too, and its address shape is checked inside the form (below).
Shapes are caught inside the form. A field’s declared shape stops the person on
the screen, with the field’s sentence under it, before the submit leaves the form:
A form carrying any shape only the data endpoint checks, or a field drawn per case, is
published endpoint-driven (as a reacting form is): every footer calls this plugin’s Flow data
endpoint, which runs the platform’s one form-shape check on the screen’s values (on a
step) or on the whole answer (on submit). A broken shape keeps the person on the screen:
the failing field shows its sentence under the control (Meta’s
error-message) and the
screen shows an error line; a clean step moves on, a clean submit completes the form.
The field’s sentence is the sender’s wording.form.fields[<name>].required for a missing
value and wording.form.fields[<name>].invalid for any other broken shape (the sender’s
words, in the end user’s language) when set, else this channel’s own word for the
broken shape. The error line is the sender’s wording.form.error_line when set, else a
Label: sentence list carrying each field’s sentence or the platform’s own sentence
naming the bound (Quantity: Enter a number of at least 1). Each field sentence is
refused at deliver past Meta’s error-message cap — 30 characters on a text field or
text area, 80 on a calendar or choice control — naming the field and the member. Meta
documents no cap on the error line. A shape the control checks itself shows Meta’s own
message on the control. When a reaction cannot update the form, the screen shows the
sender’s wording.form.update_failed (its {reason} token filled with the reaction
door’s reason, empty when there is none) else this channel’s own failure notice.
A control: chips choice draws a chip grid (single-pick for a string enum,
multi-pick for an array of strings) for Meta’s 2–20 choices, else it degrades to the
default radio/dropdown or checkbox control. Whether a field draws chips is decided by
its schema enum, so a send (or a reaction update) whose per-send option list for a
chip grid has fewer than 2 or more than 20 entries is refused, naming the property and
the count. 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). A declared date range (a rangeStart pairing) draws as one range
CalendarPicker 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 review page reads each field’s value back as text, which a range object
cannot render); either way the answer is the two date strings. A time paired with a
range end (a format: time field whose timeOf names that end) draws as a text field
right after its date — after the range picker, the start’s time and then the end’s time,
when the pair draws as one control — wherever the schema declares it. WhatsApp Flows
have no time-of-day component and no time input type, so the time is typed as HH:MM. A review page reads each
time back right after its date too, and a yes/no field as the sender’s
wording.form.fields[<name>].true_label / false_label, else Yes / No. Those
words are part of the published Flow, so a send with other words publishes its own Flow.
A field may carry a second line of text (its schema description): it draws as
the control’s helper text on a text, text-area or date field, and as a caption right
after the control on a choice control (chips included) or an opt-in, which take no
helper text — inside the same If when the field is conditional. A date range drawn
as one range control carries each end’s second line as its own caption. Helper text
is refused past 80 characters and a caption past 409 (Meta’s caps), naming the property.
A per-send option may carry a second line too (its description): on a dropdown, radio
group or checkbox group it is drawn under the option title (at most 300 characters,
Meta’s cap — a longer one is refused naming the property); a chip has no second-line
slot, so on a chip grid it is folded onto the chip’s title after an em dash
(Gold — 2-3 days), which Meta leaves uncapped.
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 (else Reply with the
text of one option.). 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,
else Answer here: ); WhatsApp draws no wording.answer button. When the callback
door rejects a form answer, the re-sent Flow’s body reads wording.answer.rejected
with its {reason} token filled with the line: the sender’s wording.form.error_line
when set, else the door’s reason led by the field label. The token is empty when there is
neither. Without wording.answer.rejected the body reads a generic lead and the line.
The template is capped at deliver so the line always fits Meta’s message body, and an
error_line longer than 500 characters is refused at deliver too.
A form that reacts while open (its ask declares reaction triggers), carries a
shape only the data endpoint checks, or draws a field per case (a visibleWhen shown for
several values) publishes as an endpoint-driven Flow whose footers
and reacting controls call this plugin’s encrypted Flow data endpoint
(/api/channels/whatsapp/flow-data, which answers the encrypted reply as a text/plain
body); a declared reaction runs only once the screen’s shapes are clean. Such a form —
an ask or an ask-less form notification alike — needs the business key material
(CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY + CHANNEL_WHATSAPP_FLOW_ENDPOINT_URI, and the
public key registered for the sending number through the admin-only
POST /api/channels/whatsapp/flow-encryption route, which derives it from the private
key) and is refused loudly, before anything is sent, without it. See the plugin README
for the provisioning steps.
Each schema property name may use any character, while a Flow component name must be
an identifier ([A-Za-z_][A-Za-z0-9_]*). The channel maps each property to a unique
identifier-safe component name for the Flow it publishes, and maps the submitted values
back to the original property names before returning the answer — so the answer keys are
always the schema’s own property names, whatever characters they use.
The submitted answer the Flow sends back is keyed by each field’s human-readable label
(its schema title, else the property name), the same label its control shows, rather
than by the identifier-safe names; the channel maps those labels back to the schema keys
when it decodes the reply. The channel sends the labels in drawing order, each paired
time right after its date.
If the platform rejects a submitted answer (a value fails the answer schema at the
door — a case no in-form check could see), the same form is re-sent — same steps,
same prefill, with the door’s reason shown above it as Label: sentence (Your last
answer could not be accepted: Email address: Enter a valid email address) — so the
participant can correct and resubmit. This repeats
up to a fixed retry cap; past the cap the participant reads the sender’s
wording.answer.rejected_final once (its {reason} filled with the same line), else
this plugin’s own the form could not be processed line, and the ask times out. A
rejected_final that could pass Meta’s 4096-character text cap with the line filled
in is refused at deliver.
One question is open per participant at a time: a second ask to the same participant
is refused while the first is pending. When the platform withdraws the open question
(a cancel, a route’s cancel_expr, or an erase of its thread, route or person), the
channel releases the participant at once and sends them nothing, so the next ask goes
out straight away. The withdrawn form stays in the chat; a late reply to it arrives as
a new inbound turn.
Inbound entry parameters
When an inbound message enters the conversation bridge as a fresh turn (no pending question to answer), the channel forwards opaque entry parameters alongside the turn text, delivered verbatim to atool target’s payload under
params. The platform attaches no meaning and no trust; a consumer opts into the
keys it understands:
reply_id(+reply_descriptionfor a list row) — an interactive tap that is not an answer carries its question-bound wire id.button_payload— a template quick-reply tap (buttonmessage) carries the developer payload behind the visible button text.referral_source_url/referral_source_id/referral_source_type/referral_ctwa_clid/referral_headline/referral_body— a click-to-WhatsApp or QR entry (referral) forwards its fields.media_kind/media_id/media_mime_type/media_sha256/media_size/media_filename/media_voice/sticker_animated— an inbound media message (image/document/audio/video/sticker) bridges with its caption as the turn text and its ingested identity in these params (see Inbound media below).reaction_emoji/reaction_message_id— an inbound emoji reaction.contacts_count/contacts— shared contact card(s).
WARNING (naming the door and the bound, never a value) and counted on
tai_entry_params_dropped_total{door="whatsapp"}.
Participant media, location, contacts, and reactions all bridge as
turns. An inbound location additionally lands as a typed LocationElement on
the turn (not a param).
Replies
A message sent as a reply to an earlier one carries Meta’scontext.id, the wamid of
the message it quotes. The channel hands that id to the platform beside the message (never
as an entry parameter), and the platform resolves it against the conversation’s own records
into a typed reply_to ({message_id, status, text}): a tool target reads it in its
payload, an agent target through start_expr. An interactive tap (a reply button or a
list row), a template quick-reply and a completed Flow carry the context.id of the message
that held the buttons or the Flow, so each is a reply to that message.
When the reply answers a pending question, the asker receives the same reply_to beside
the unchanged answer. WhatsApp sends no entry parameters with an answer, so the asker’s
params is null. A reaction is not a reply: reaction_message_id names the message the
emoji was applied to.
Inbound media
When a participant sends media (image / document / audio / video / sticker), the channel looks up the Graph media object’s short-lived lookaside url, streams the bytes, and ingests them into the platform’s served-media store. The bridged turn then carries both a typedattachments entry — a MediaItem whose url is the served
/api/interactions/media/<id> reference — and the parity media_* params off that one
ingest (media_id is the served id, media_mime_type the sniffed-and-agreed type,
media_sha256/media_size the ingested content’s digest and byte size, media_filename
the sanitised document name). The caption is the turn text, or a [kind] placeholder when
there is none.
If the bytes cannot be received, the participant is told once and the media is not bridged:
- too large — the object exceeds the operator’s per-kind byte cap;
- unsupported type — the sniffed content is not on its kind’s allowlist (or the declared and sniffed types disagree);
- could not receive — the media object has expired or is gone, or no media store is configured.
5xx, a timeout, or a torn read)
is not rejected: the webhook returns non-2xx, Meta redelivers, and the per-message dedupe
guards against a double turn.
Params ride only on the bridge path — a tap or quick-reply that answers a
pending question surfaces none (the answer path carries no params). Meta inbound
error notices (errors[]) are always logged at warning and never bridged; an
unsupported-message-type notice (code 131051) — participant content the
channel cannot receive — additionally sends the participant one generic
“unsupported content” reply and records a rejection event, while every other error
code stays log-only (an operator-facing vendor signal, not participant content). An
unhandled participant content type the plugin has no handler for likewise gets
the generic reply + event; a vendor non-content notification (system /
request_welcome) stays log-only. Each reply is sent once, deduped per message.
The 24-hour window and templates
WhatsApp delivers freeform messages only inside a 24-hour service window opened by the client’s last message. Outside it, a freeform reply is rejected (synchronously, or later via afailed delivery-status webhook), and the record
ends failed rather than silently vanishing. Read failures with:
phone_number_id, wa_id) and reports
it on every bridged turn as turn.reply_window ({state, closes_at}), so a
route’s start_expr, reply_expr or tool can choose a template before Meta
refuses a freeform reply:
- every client message (text, media, reaction, an interactive tap, a Flow reply,
an unsupported type) moves the window to its own Meta
timestampplus 24 hours, never backwards, so a redelivered or out-of-order webhook cannot shorten it; a timestamp ahead of the replica’s clock counts as now; - a platform notice (
system,request_welcome) does not open it; - a message whose
timestampis missing or unreadable opens nothing and logs a warning naming the message; stateisopen(untilcloses_at, UTC) orclosed(never opened, or expired).
CHANNEL_WHATSAPP_REDIS_URL), one key
per pair that expires when the window closes. There is no setting: the 24 hours
are Meta’s rule. The state is read once per turn, so a turn that runs past
closes_at may still see open; Meta’s 131047 rejection stays the final check.
A pre-approved template is the one message delivered outside the window. Send
one with notify_user’s template input: a name, a language, an optional
header_media (image/video/document), positional body_parameters (body-text
fills), and per-button buttons (a quick-reply payload or a url suffix). Because
a template is billed and delivered cold, the
channel fences who may receive one: the recipient must be on
CHANNEL_WHATSAPP_ALLOWED_RECIPIENTS, or be a known contact — a number the
inbound webhook received a message from within
CHANNEL_WHATSAPP_TEMPLATE_CONTACT_WINDOW_DAYS. A template send to a cold,
unlisted number is refused loudly.
Message ids and addresses
The conversation bridge records every message this channel sends to a participant. These are the ids and addresses it records:- An inbound message, and a reply to a question, passes its
wamidas the medium message id. resolve_addressreturns the sending phone number id and the recipient as the caller names it. A completed send reports Meta’scontacts[0].wa_idfor the recipient, the id an inbound message from them carries asfrom; it can differ in form from the number the send named. A send that went out as several messages reports everywamid, in send order.
Working-on-it signal
While a message runs its turn, the platform keeps WhatsApp’s typing indicator alive: it re-sends the indicator ahead of Meta’s ~25 s dismissal window until the reply is sent, so the participant sees it for the whole wait rather than only the first few seconds. The refresh is bounded by theCONVERSATIONS_WORKING_SIGNAL_MAX_SECONDS ceiling (0 disables it entirely), with
CONVERSATIONS_WORKING_SIGNAL_REFRESH_MARGIN_SECONDS the margin it refreshes ahead
of each lapse. An inbound message is marked read the moment it is received,
independent of the indicator.
See also
- Client conversations — the bridge model and delivery outcomes.
- Interactions — the answer contract for a delivered question.
- Conversation bridge — every setting and the authorization model.

