@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-whatsapp

npm · 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.ts JSDoc, 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
  • CHANNEL_WHATSAPP_PHONE_NUMBER_ID (required) — WhatsApp phone number ID
  • CHANNEL_WHATSAPP_APP_SECRET (required) — Meta app secret (WhatsApp)

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_activity tool (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 undefined placeholders, no secrets).
  • A failed send (unbonded or misconfigured provider) is visible in logs/UI — never silently swallowed.