@molecule/api-channel-whatsapp
Provider bond · channel · API (Node) · v1.0.2 · Apache-2.0
WhatsApp channel bond — implements @molecule/api-channel via the WhatsApp Cloud API for sendMessage (text/template/interactive), x-hub-signature-256 HMAC-SHA256 webhook verification, and inbound message parsing.
npm install @molecule/api-channel-whatsappnpm · Source on GitHub · Implements @molecule/api-channel
How it works
@molecule/api-channel-whatsapp is a provider bond on the API (Node) side: it implements the channel core interface (@molecule/api-channel) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/api-channel'
import { provider } from '@molecule/api-channel-whatsapp'
setProvider('whatsapp', provider)Works with: @molecule/api-secrets
Secrets: CHANNEL_WHATSAPP_ACCESS_TOKEN, CHANNEL_WHATSAPP_PHONE_NUMBER_ID, CHANNEL_WHATSAPP_APP_SECRET
Reference
WhatsApp channel provider for molecule.dev.
Implements the framework-agnostic ChannelProvider interface
over the WhatsApp Cloud API
(https://graph.facebook.com/v22.0/<phone-id>/messages). Bond under
the named-multi-provider 'channel' category at app startup:
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/api-channel'
import { provider } from '@molecule/api-channel-whatsapp'
setProvider('whatsapp', provider)
Type
provider
Installation
npm install @molecule/api-channel-whatsapp @molecule/api-channel @molecule/api-secrets
API
Interfaces
WhatsAppConfig
Configuration for the WhatsApp channel provider.
The access token is the credential that authorizes Cloud API calls —
it is deliberately accepted only via this config (or the
CHANNEL_WHATSAPP_ACCESS_TOKEN env var) and is NEVER included in
error messages, log lines, or normalized payloads.
interface WhatsAppConfig {
/**
* WhatsApp Cloud API access token (typically a long-lived `EAA…`
* system-user token). Defaults to the
* `CHANNEL_WHATSAPP_ACCESS_TOKEN` env var.
*
* Treat as a secret — providers redact this value in any user-facing
* output.
*/
accessToken?: string
/**
* The numeric phone-number id assigned to the business in the Meta
* developer dashboard (NOT the E.164 phone number). Used to build the
* Cloud API endpoint. Defaults to the
* `CHANNEL_WHATSAPP_PHONE_NUMBER_ID` env var.
*/
phoneNumberId?: string
/**
* Meta App secret used to verify inbound `X-Hub-Signature-256`
* webhook signatures. Defaults to the `CHANNEL_WHATSAPP_APP_SECRET`
* env var.
*
* If unset, `WhatsAppChannelProvider.verifyWebhookSignature`
* returns `false`.
*/
appSecret?: string
/**
* Cloud API base URL. Override only for tests / staging endpoints.
* Defaults to `https://graph.facebook.com`.
*/
apiBaseUrl?: string
/**
* Cloud API version segment (e.g. `'v22.0'`). Defaults to `'v22.0'`.
*/
apiVersion?: string
/**
* Per-request timeout in milliseconds. Defaults to 10000.
*/
timeoutMs?: number
}
ProcessEnv
Environment variables consumed by the WhatsApp channel provider.
interface ProcessEnv {
/** Cloud API access token. */
CHANNEL_WHATSAPP_ACCESS_TOKEN: string
/** Numeric phone-number id of the WhatsApp business sender. */
CHANNEL_WHATSAPP_PHONE_NUMBER_ID: string
/** Meta App secret used to verify `X-Hub-Signature-256`. */
CHANNEL_WHATSAPP_APP_SECRET: string
}
WhatsAppTemplateRef
Identifies a WhatsApp template message to send. Templates are the only message form allowed outside the 24-hour customer-service window. The template (and its variables) must be pre-approved in the Meta WhatsApp Manager.
interface WhatsAppTemplateRef {
/** Approved template name (e.g. `'order_confirmation'`). */
name: string
/** Template language code (e.g. `'en_US'`, `'es'`). */
language: string
/**
* Optional positional body parameters substituted into the template
* `{{1}}`, `{{2}}` placeholders. WhatsApp expects strings.
*/
bodyParameters?: string[]
}
WhatsAppOutboundExtensions
Optional WhatsApp-specific extensions carried via
OutboundMessage.payload. Not part of the core
OutboundMessage contract — providers SHOULD ignore unknown
fields.
interface WhatsAppOutboundExtensions {
/**
* When set, the message is sent as a WhatsApp template instead of a
* free-form text / interactive message.
*/
template?: WhatsAppTemplateRef
}
WhatsAppSendResponse
Subset of the WhatsApp Cloud API outbound messages response that
the provider inspects to build a SendResult.
interface WhatsAppSendResponse {
/** Echoed phone-number id of the sending business. */
messaging_product?: string
/**
* Per-recipient envelope. Cloud API always returns at least one
* entry on success.
*/
messages?: Array<{
/** WhatsApp message id (e.g. `'wamid.HBgL…'`). */
id: string
}>
/**
* Cloud API error object on failure responses. Surfaced into the
* thrown error message after token redaction.
*/
error?: {
message?: string
type?: string
code?: number
}
}
WhatsAppContact
Subset of the WhatsApp inbound contact object surfaced via
InboundMessage.payload.
interface WhatsAppContact {
/** Display name set by the user. */
profile?: { name?: string }
/** Sender's WhatsApp id (typically the E.164 phone number). */
wa_id: string
}
WhatsAppMediaRef
Subset of the WhatsApp inbound media descriptor, shared by
image / audio / video / document / sticker envelopes.
interface WhatsAppMediaRef {
/** Cloud API media id (download via `/v22.0/<id>`). */
id?: string
/** Reported MIME type. */
mime_type?: string
/** Original filename (documents only). */
filename?: string
/** Optional caption (image / video / document). */
caption?: string
}
WhatsAppLocation
Subset of the WhatsApp inbound location object.
interface WhatsAppLocation {
/** Geographic latitude in decimal degrees. */
latitude: number
/** Geographic longitude in decimal degrees. */
longitude: number
/** Optional human-readable place name. */
name?: string
/** Optional human-readable street address. */
address?: string
}
WhatsAppButtonReply
Subset of the inbound button reply object (template button click).
interface WhatsAppButtonReply {
/** Opaque payload originally set on the template button. */
payload?: string
/** Visible label of the button that was clicked. */
text?: string
}
WhatsAppInteractiveReply
Subset of the inbound interactive reply object (button-list /
list-reply click on an interactive message).
interface WhatsAppInteractiveReply {
/**
* Whether this was a `'button_reply'` or `'list_reply'`.
*/
type?: string
button_reply?: { id?: string; title?: string }
list_reply?: { id?: string; title?: string; description?: string }
}
WhatsAppInboundMessage
Subset of the inbound messages[] entry. Only fields the provider
actually inspects are typed; others are passed through opaquely on
InboundMessage.payload.
interface WhatsAppInboundMessage {
/** WhatsApp message id (`wamid.…`). */
id: string
/** Sender's WhatsApp id (E.164 phone number). */
from: string
/** Unix timestamp (seconds, as a string) reported by WhatsApp. */
timestamp?: string
/**
* Discriminator: `'text'`, `'image'`, `'audio'`, `'video'`,
* `'document'`, `'sticker'`, `'location'`, `'button'`,
* `'interactive'`, `'reaction'`, `'contacts'`, `'unsupported'`, …
*/
type?: string
/** Text body for `type === 'text'`. */
text?: { body?: string }
/** Inline media payloads. */
image?: WhatsAppMediaRef
audio?: WhatsAppMediaRef
video?: WhatsAppMediaRef
document?: WhatsAppMediaRef
sticker?: WhatsAppMediaRef
/** Geo payload for `type === 'location'`. */
location?: WhatsAppLocation
/** Template-button click for `type === 'button'`. */
button?: WhatsAppButtonReply
/** Interactive-button / list-reply for `type === 'interactive'`. */
interactive?: WhatsAppInteractiveReply
/** Reply context — present when this message replies to another. */
context?: { id?: string; from?: string }
}
WhatsAppWebhookValue
Subset of the entry[].changes[].value payload in a Cloud API
webhook event.
interface WhatsAppWebhookValue {
/** Always `'whatsapp'` for Cloud API events. */
messaging_product?: string
/** Sender / receiver metadata (display phone, phone-number id). */
metadata?: { phone_number_id?: string; display_phone_number?: string }
/** Inbound contacts that triggered this event. */
contacts?: WhatsAppContact[]
/** Inbound messages that triggered this event. */
messages?: WhatsAppInboundMessage[]
}
WhatsAppWebhookPayload
Top-level shape of an inbound WhatsApp Cloud API webhook payload.
interface WhatsAppWebhookPayload {
/** Always `'whatsapp_business_account'` for Cloud API events. */
object?: string
/** Top-level entries (one per WABA). */
entry?: Array<{
/** WhatsApp Business Account id. */
id?: string
/** Per-product changes (`'messages'`, `'message_template_status_update'`, …). */
changes?: Array<{
field?: string
value?: WhatsAppWebhookValue
}>
}>
}
Classes
WhatsAppChannelProvider
WhatsApp channel provider — implements the framework-agnostic
ChannelProvider contract on top of the WhatsApp Cloud API.
class WhatsAppChannelProvider implements ChannelProvider {
/**
* Stable channel name. Conventionally matches the bond name this
* provider is registered under (`bond('channel', 'whatsapp', ...)`).
*/
readonly name = 'whatsapp'
private readonly accessToken: string | undefined
private readonly phoneNumberId: string | undefined
private readonly appSecret: string | undefined
private readonly apiBaseUrl: string
private readonly apiVersion: string
private readonly timeoutMs: number
/**
* Constructs a WhatsApp channel provider.
*
* @param config - Optional configuration. Access token, phone-number
* id, and app secret fall back to `CHANNEL_WHATSAPP_ACCESS_TOKEN`,
* `CHANNEL_WHATSAPP_PHONE_NUMBER_ID`, and
* `CHANNEL_WHATSAPP_APP_SECRET` environment variables when
* omitted.
*/
constructor(config: WhatsAppConfig = {}) {
this.accessToken = config.accessToken ?? process.env.CHANNEL_WHATSAPP_ACCESS_TOKEN
this.phoneNumberId = config.phoneNumberId ?? process.env.CHANNEL_WHATSAPP_PHONE_NUMBER_ID
this.appSecret = config.appSecret ?? process.env.CHANNEL_WHATSAPP_APP_SECRET
this.apiBaseUrl = (config.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '')
this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
}
/**
* Sends an outbound message via the Cloud API.
*
* Routes to a text, interactive, template, or media message based on
* the normalized payload:
* - `kind === 'rich'` with `payload.template` → template message
* - `kind === 'rich'` with `buttons` → interactive button message
* (in-window only)
* - `kind === 'media'` with at least one `url` attachment → media
* message
* - Otherwise → plain text
*
* @param to - WhatsApp recipient id (E.164 phone number).
* @param message - Normalized `OutboundMessage`.
* @returns A `SendResult` keyed by the WhatsApp `wamid`.
* @throws {Error} If the access token / phone-number id is unset or
* the Cloud API rejects the call. The access token is never
* included in error messages.
*/
async sendMessage(to: ChannelRecipient, message: OutboundMessage): Promise<SendResult> {
if (!this.accessToken) {
throw new Error('WhatsApp access token is not configured.')
}
if (!this.phoneNumberId) {
throw new Error('WhatsApp phone-number id is not configured.')
}
const extensions = (message as OutboundMessage & { payload?: WhatsAppOutboundExtensions })
.payload
const body = this.pickRequestBody(to, message, extensions)
const result = await this.callCloudApi<WhatsAppSendResponse>(body)
const id = result.messages?.[0]?.id
if (!id) {
throw new Error('WhatsApp Cloud API response did not include a message id.')
}
return {
messageId: id,
deliveredAt: new Date(),
}
}
/**
* Verifies the inbound webhook signature.
*
* WhatsApp signs the raw request body with HMAC-SHA256 keyed on the
* Meta App secret and delivers the digest in the
* `X-Hub-Signature-256: sha256=<hex>` header. We recompute and
* compare in constant time.
*
* @param headers - Request headers (case-insensitive lookup).
* @param body - Raw request body bytes.
* @returns `true` when the signature matches the configured secret.
*/
verifyWebhookSignature(headers: Record<string, string>, body: string | Uint8Array): boolean {
if (!this.appSecret) return false
const lower = normaliseHeaders(headers)
const provided = lower[SIGNATURE_HEADER]
if (!provided || !provided.startsWith(SIGNATURE_PREFIX)) return false
const givenHex = provided.slice(SIGNATURE_PREFIX.length)
const expectedHex = createHmac('sha256', this.appSecret)
.update(bodyToBuffer(body))
.digest('hex')
return safeHexEqual(givenHex, expectedHex)
}
/**
* Normalizes a raw WhatsApp Cloud API webhook body into an
* `InboundMessage`.
*
* Handles `entry[].changes[].value.messages[]` events — the
* `messages` change. Status / template-status events return a
* payload-only inbound (no text, no attachments).
*
* @param payload - Raw webhook body (parsed JSON).
* @returns Canonical `InboundMessage`. The original payload is
* preserved on `payload` for forensic / channel-specific handling.
* @throws {Error} If the payload is not a valid Cloud API webhook
* body.
*/
parseInbound(payload: unknown): InboundMessage {
if (!isPlainObject(payload)) {
throw new Error('WhatsApp inbound payload is not an object.')
}
const wrapper = payload as WhatsAppWebhookPayload
const change = wrapper.entry?.[0]?.changes?.[0]
const value = change?.value
if (!value) {
throw new Error('WhatsApp inbound payload did not match a supported webhook variant.')
}
const message = value.messages?.[0]
if (message) {
return inboundFromMessage(message, wrapper)
}
// Status / template-status updates are still well-formed Cloud API
// events; surface them as a sender-less inbound so consumers can
// route on `payload` rather than throwing.
return {
from: value.metadata?.display_phone_number ?? '',
channel: 'whatsapp',
payload: wrapper,
receivedAt: new Date(),
}
}
/**
* Returns the static capability matrix for WhatsApp Cloud.
*
* WhatsApp supports plain text, interactive buttons (in 24h window),
* pre-approved templates (anytime), media attachments, and
* reactions. Threading is not supported (only reply-to via
* `context.message_id`). Inbound webhooks are HMAC-SHA256 signed.
*
* @returns The `ChannelFeatures` flags for WhatsApp.
*/
listSupportedFeatures(): ChannelFeatures {
return whatsappFeatures()
}
/**
* Picks the Cloud API request body for a normalized outbound
* message.
*
* @param to - Recipient WhatsApp id.
* @param message - Normalized outbound message.
* @param extensions - Optional WhatsApp-specific extensions read
* from `OutboundMessage.payload`.
* @returns A JSON-serializable Cloud API request body.
*/
private pickRequestBody(
to: ChannelRecipient,
message: OutboundMessage,
extensions: WhatsAppOutboundExtensions | undefined,
): Record<string, unknown> {
if (extensions?.template) {
return buildTemplateBody(to, extensions.template)
}
if (message.kind === 'rich' && message.buttons && message.buttons.length > 0) {
return buildInteractiveBody(to, message.text, message.buttons)
}
if (message.kind === 'media' && message.attachments && message.attachments.length > 0) {
const first = message.attachments[0]
if (first) {
const media = buildMediaBody(to, {
...first,
...(message.text !== undefined && first.caption === undefined
? { caption: message.text }
: {}),
})
if (media) return media
}
}
return buildTextBody(to, message.text ?? '')
}
/**
* Performs a single Cloud API call. Centralizes redaction so the
* access token never ends up in thrown error messages.
*
* @param body - JSON-serializable request payload.
* @returns The parsed Cloud API response body on `{ messages: ... }`
* success.
* @throws {Error} On non-2xx responses, error envelopes, or network
* failures. Errors quote the redacted endpoint URL only.
*/
private async callCloudApi<T>(body: unknown): Promise<T> {
const url = `${this.apiBaseUrl}/${this.apiVersion}/${this.phoneNumberId}/messages`
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
let response: Response
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// … truncated — the full declaration is in node_modules/@molecule/api-channel-whatsapp/dist/provider.d.ts
Functions
createProvider(config)
Convenience factory for the named-multi-provider bond pattern.
function createProvider(config?: WhatsAppConfig): WhatsAppChannelProvider
config— Optional WhatsApp config.
Returns: A new WhatsAppChannelProvider instance.
Constants
provider
Lazily-instantiated singleton instance for app-startup wiring. Reads configuration from environment variables on first use.
const provider: ChannelProvider
channelWhatsappSecretDefinitions
Secret definitions required by the WhatsApp channel bond.
const channelWhatsappSecretDefinitions: SecretDefinition[]
Core Interface
Implements @molecule/api-channel interface.
Injection Notes
Requirements
Peer dependencies:
@molecule/api-channel^1.0.1@molecule/api-secrets^1.0.1
Environment Variables
CHANNEL_WHATSAPP_ACCESS_TOKEN(required) — WhatsApp access token- Setup: Create a Meta app with the WhatsApp product and generate a permanent access token (System User).
- Get it here: https://developers.facebook.com/apps
CHANNEL_WHATSAPP_PHONE_NUMBER_ID(required) — WhatsApp phone number ID- Setup: WhatsApp → API Setup → Phone number ID (not the phone number itself).
- Get it here: https://developers.facebook.com/apps
CHANNEL_WHATSAPP_APP_SECRET(required) — Meta app secret (WhatsApp)- Setup: Your Meta app → App settings → Basic → App secret (verifies webhook signatures).
- Get it here: https://developers.facebook.com/apps
Runtime Dependencies
@molecule/api-channel@molecule/api-secrets
Outbound messages outside the WhatsApp 24-hour customer-service window
MUST be sent as approved templates. The provider exposes
OutboundMessage.kind = 'rich' two ways: via interactive button
objects (in-window) or via WhatsApp templates (out-of-window) when
payload.template is supplied.
Webhook subscription needs a GET echo the bond does not provide. When
registering the webhook URL in the Meta console, Meta sends
GET ?hub.mode=subscribe&hub.verify_token=<your token>&hub.challenge=<n>;
your route must validate the verify token and respond 200 with the raw
hub.challenge. Only POST deliveries flow through
verifyWebhookSignature() / parseInbound().
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Each channel-notifying flow the app defines (a Slack/Discord alert on
a new order, a status-change message) actually produces a message. The
sandbox CAPTURES channel messages instead of sending — read them with the
read_activitytool (filter type 'channel'); never mock the flow or modify production code to expose the message. - The captured message targets the configured channel/provider name and
carries the app's real content (readable text, no
undefinedplaceholders, no secrets). - A failed send (unbonded or misconfigured provider) is visible in logs/UI — never silently swallowed.