Skip to main content
Channel plugin · listing tai42/channel-whatsapp

Install

Permissions

Provides

whatsapp

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

The CHANNEL_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
On subscribe, Meta sends a 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:
In Studio, pick whatsapp as the route’s Channel; the Our identity field then shows this hint.

Rich messages

  • Interactive select asks — when a select question 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_user renders 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 authored id on the wire when set; a lone LinkOption → a cta_url URL 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/url buttons). A choice surface, schema, and template are 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 matching supports_* capability flag).

Form asks

A form 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 a tool target’s payload under params. The platform attaches no meaning and no trust; a consumer opts into the keys it understands:
  • reply_id (+ reply_description for a list row) — an interactive tap that is not an answer carries its question-bound wire id.
  • button_payload — a template quick-reply tap (button message) 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).
Entry params are bounded at the door (at most 16 keys, each value at most 512 characters, the whole set at most 2048 bytes). Because Meta redelivers a failed webhook, this door drops rather than refuses: an over-cap value is dropped on its own, an over-bound set is dropped whole, and the participant’s message still bridges. Every drop is logged at 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’s context.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 typed attachments 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.
When a caption rode with a rejected media, the caption still bridges as a text-only turn so the message is never lost. A transient fetch fault (a 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 a failed delivery-status webhook), and the record ends failed rather than silently vanishing. Read failures with:
The channel tracks that window per pair (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 timestamp plus 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 timestamp is missing or unreadable opens nothing and logs a warning naming the message;
  • state is open (until closes_at, UTC) or closed (never opened, or expired).
The window lives in the channel’s Redis (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 wamid as the medium message id.
  • resolve_address returns the sending phone number id and the recipient as the caller names it. A completed send reports Meta’s contacts[0].wa_id for the recipient, the id an inbound message from them carries as from; it can differ in form from the number the send named. A send that went out as several messages reports every wamid, 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 the CONVERSATIONS_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