@molecule/api-channel-messenger

Provider bond · channel · API (Node) · v1.0.2 · Apache-2.0

Facebook Messenger channel bond — implements @molecule/api-channel for sendMessage via the Send API, X-Hub-Signature-256 webhook verification, and inbound entry/messaging parsing.

npm install @molecule/api-channel-messenger

npm · Source on GitHub · Implements @molecule/api-channel

How it works

@molecule/api-channel-messenger 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-messenger'

setProvider('messenger', provider)

Works with: @molecule/api-secrets

Secrets: CHANNEL_MESSENGER_PAGE_ACCESS_TOKEN, CHANNEL_MESSENGER_APP_SECRET

Reference

Facebook Messenger channel provider for molecule.dev.

Implements the framework-agnostic ChannelProvider interface over the Messenger Send API and webhook envelope. 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-messenger'

setProvider('messenger', provider)

Type

provider

Installation

npm install @molecule/api-channel-messenger @molecule/api-channel @molecule/api-secrets

API

Interfaces

MessengerConfig

Configuration for the Messenger channel provider.

The page access token authorizes Send API calls; the app secret signs inbound webhooks via X-Hub-Signature-256. Both are deliberately accepted only via this config (or the CHANNEL_MESSENGER_PAGE_ACCESS_TOKEN / CHANNEL_MESSENGER_APP_SECRET env vars) and are NEVER included in error messages, log lines, or normalized payloads.

interface MessengerConfig {
  /**
   * Page access token (`EAA…`). Required for outbound `sendMessage`.
   * Defaults to the `CHANNEL_MESSENGER_PAGE_ACCESS_TOKEN` env var.
   *
   * Treat as a secret — providers redact this value in any user-facing
   * output.
   */
  pageAccessToken?: string

  /**
   * Facebook app secret used to verify `X-Hub-Signature-256` on inbound
   * webhook requests. Defaults to the `CHANNEL_MESSENGER_APP_SECRET` env
   * var.
   *
   * If unset, `MessengerChannelProvider.verifyWebhookSignature`
   * returns `false`.
   */
  appSecret?: string

  /**
   * Graph API base URL. Override only for tests or alternative regional
   * endpoints. Defaults to `https://graph.facebook.com`.
   */
  apiBaseUrl?: string

  /**
   * Graph API version to target. Defaults to `'v22.0'`.
   */
  apiVersion?: string

  /**
   * Per-request timeout in milliseconds. Defaults to 10000.
   */
  timeoutMs?: number

  /**
   * Optional default `messaging_type` applied to outbound sends. Defaults
   * to `'RESPONSE'` (replies to user-initiated conversations within the
   * 24-hour window). Override to `'UPDATE'` or `'MESSAGE_TAG'` when
   * sending unsolicited messages — note the Messenger Platform policy
   * restrictions.
   */
  defaultMessagingType?: MessengerMessagingType
}

ProcessEnv

Environment variables consumed by the Messenger channel provider.

interface ProcessEnv {
  /** Page access token (`EAA…`). Required for outbound sends. */
  CHANNEL_MESSENGER_PAGE_ACCESS_TOKEN: string

  /** Facebook app secret used to verify inbound webhook signatures. */
  CHANNEL_MESSENGER_APP_SECRET: string
}

MessengerActor

Subset of the Messenger Sender/Recipient shape used during inbound normalization.

interface MessengerActor {
  /** Page-scoped user identifier. */
  id: string
}

MessengerInboundAttachment

Subset of an inbound Messenger attachment object.

interface MessengerInboundAttachment {
  /** Attachment kind (`'image'`, `'video'`, `'audio'`, `'file'`, …). */
  type?: string
  /** Optional payload object — typically `{ url }`. */
  payload?: { url?: string; sticker_id?: number }
}

MessengerInboundMessage

Subset of an inbound Messenger message object.

interface MessengerInboundMessage {
  /** Provider-assigned message identifier (mid). */
  mid?: string
  /** Plain-text body. */
  text?: string
  /** Quick-reply payload, when the user clicked a quick reply. */
  quick_reply?: { payload?: string }
  /** Attachments (images, files, …) included with the message. */
  attachments?: MessengerInboundAttachment[]
  /** Whether the message was an echo of one this app sent. */
  is_echo?: boolean
}

MessengerInboundPostback

Subset of an inbound Messenger postback object — the payload returned when a user taps a button on a button_template or persistent menu.

interface MessengerInboundPostback {
  /** Opaque payload originally set on the button. */
  payload?: string
  /** Visible title shown on the button when it was tapped. */
  title?: string
}

MessengerInboundDelivery

Subset of an inbound Messenger delivery object.

interface MessengerInboundDelivery {
  /** Mids of the messages confirmed delivered. */
  mids?: string[]
  /** Watermark — all messages sent before this timestamp are delivered. */
  watermark?: number
}

MessengerInboundRead

Subset of an inbound Messenger read object.

interface MessengerInboundRead {
  /** Watermark — all messages sent before this timestamp are read. */
  watermark?: number
}

MessengerMessagingEntry

A single messaging entry inside an inbound webhook envelope.

interface MessengerMessagingEntry {
  /** Sender of the inbound event. */
  sender?: MessengerActor
  /** Recipient (typically the page receiving the event). */
  recipient?: MessengerActor
  /** Unix timestamp in milliseconds. */
  timestamp?: number
  /** Inbound user message. */
  message?: MessengerInboundMessage
  /** Inbound button-tap postback. */
  postback?: MessengerInboundPostback
  /** Delivery confirmation. */
  delivery?: MessengerInboundDelivery
  /** Read receipt. */
  read?: MessengerInboundRead
}

MessengerWebhookEntry

A single entry inside an inbound webhook envelope.

interface MessengerWebhookEntry {
  /** Page identifier the events belong to. */
  id?: string
  /** Unix timestamp in milliseconds. */
  time?: number
  /** Per-conversation events. Typically a single-element array. */
  messaging?: MessengerMessagingEntry[]
}

MessengerWebhookPayload

Top-level Messenger webhook envelope.

interface MessengerWebhookPayload {
  /** Always `'page'` for Messenger Platform webhooks. */
  object?: string
  /** Per-page event groups. */
  entry?: MessengerWebhookEntry[]
}

MessengerSendApiResponse

Successful Send API response shape used by the provider.

interface MessengerSendApiResponse {
  /** Page-scoped recipient identifier (echoed). */
  recipient_id?: string
  /** Messenger-assigned outbound message id. */
  message_id?: string
}

Types

MessengerMessagingType

Messenger messaging_type values accepted on the Send API.

type MessengerMessagingType = 'RESPONSE' | 'UPDATE' | 'MESSAGE_TAG'

Classes

MessengerChannelProvider

Concrete Messenger Platform implementation of ChannelProvider.

class MessengerChannelProvider implements ChannelProvider {
  /**
   * Stable channel name. Conventionally matches the bond name this
   * provider is registered under (`bond('channel', 'messenger', ...)`).
   */
  readonly name = 'messenger'

  private readonly pageAccessToken: string | undefined
  private readonly appSecret: string | undefined
  private readonly apiBaseUrl: string
  private readonly apiVersion: string
  private readonly timeoutMs: number
  private readonly defaultMessagingType: MessengerMessagingType

  /**
   * Constructs a Messenger channel provider.
   *
   * @param config - Optional configuration. Page access token and app
   *   secret fall back to `CHANNEL_MESSENGER_PAGE_ACCESS_TOKEN` and
   *   `CHANNEL_MESSENGER_APP_SECRET` environment variables when omitted.
   */
  constructor(config: MessengerConfig = {}) {
    this.pageAccessToken = config.pageAccessToken ?? process.env.CHANNEL_MESSENGER_PAGE_ACCESS_TOKEN
    this.appSecret = config.appSecret ?? process.env.CHANNEL_MESSENGER_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
    this.defaultMessagingType = config.defaultMessagingType ?? 'RESPONSE'
  }

  /**
   * Sends an outbound message via the Messenger Send API.
   *
   * Plain text messages POST `{ message: { text } }`. Rich messages with
   * 1–3 buttons render as a `button_template`; 4+ buttons fall back to
   * `quick_replies` (which is the only Messenger affordance that scales
   * past three options on a single message). Media messages POST an
   * `attachment` whose `type` is inferred from the attachment's content
   * type or filename extension.
   *
   * @param to - Page-scoped user identifier (PSID) of the recipient.
   * @param message - Normalized `OutboundMessage`.
   * @returns A `SendResult` keyed by Messenger's `message_id`.
   * @throws {Error} If the page access token is unset or the Send API
   *   rejects the call. The token is never included in error messages.
   */
  async sendMessage(to: ChannelRecipient, message: OutboundMessage): Promise<SendResult> {
    if (!to) {
      throw new Error('Messenger recipient (PSID) is required.')
    }
    if (!this.pageAccessToken) {
      throw new Error('Messenger page access token is not configured.')
    }

    const body = buildRequestBody(to, message, this.defaultMessagingType)
    const result = await this.callSendApi(body)

    return {
      messageId: result.message_id ?? '',
      deliveredAt: new Date(),
    }
  }

  /**
   * Verifies the inbound webhook signature.
   *
   * Messenger signs each webhook request with HMAC-SHA256 of the raw body
   * keyed by the app secret, prefixed with `'sha256='` and delivered in
   * the `X-Hub-Signature-256` header. We compute the same digest and
   * compare in constant time.
   *
   * @param headers - Request headers (case-insensitive lookup).
   * @param body - Raw, unparsed request body.
   * @returns `true` when the signature is valid for the configured app
   *   secret. Returns `false` if the secret is unset, the header is
   *   missing, or the digests do not match.
   */
  verifyWebhookSignature(headers: Record<string, string>, body: string | Uint8Array): boolean {
    if (!this.appSecret) {
      return false
    }

    const provided = lookupHeader(headers, SIGNATURE_HEADER)
    if (!provided) {
      return false
    }

    const match = /^sha256=([0-9a-fA-F]+)$/.exec(provided.trim())
    if (!match) {
      return false
    }
    const providedDigest = match[1]!.toLowerCase()

    const bodyBytes = typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.from(body)
    const computed = createHmac('sha256', this.appSecret).update(bodyBytes).digest('hex')

    return constantTimeHexEqual(computed, providedDigest)
  }

  /**
   * Normalizes a raw Messenger webhook payload into an
   * `InboundMessage`.
   *
   * Messenger delivers events as `entry[].messaging[]` — typically a
   * single messaging entry per webhook, but the spec allows batching.
   * This method walks the messaging array and returns the first event it
   * recognizes:
   *
   * - `message` (text or attachment) → text + attachments
   * - `message.quick_reply` → quick-reply payload as `text`
   * - `postback` → button payload as `text`
   * - `delivery` / `read` → bare receipt with `payload` preserved
   *
   * Anything else throws — callers MUST verify the signature before
   * reaching this method.
   *
   * @param payload - Raw Messenger webhook envelope (parsed JSON).
   * @returns Canonical `InboundMessage`. The original payload is
   *   preserved on `payload` for forensic / channel-specific handling.
   * @throws {Error} If the payload does not match a recognized variant.
   */
  parseInbound(payload: unknown): InboundMessage {
    if (!isPlainObject(payload)) {
      throw new Error('Messenger inbound payload is not an object.')
    }

    const envelope = payload as MessengerWebhookPayload
    const entry = envelope.entry?.[0]
    const messaging = entry?.messaging?.[0]
    if (!messaging) {
      throw new Error('Messenger inbound payload has no messaging entries.')
    }

    return inboundFromMessaging(messaging, payload)
  }

  /**
   * Returns the static capability matrix for the Messenger Send API.
   *
   * Reactions are technically supported on Messenger but the Bot Send API
   * surface is limited (bots cannot react to arbitrary messages); the
   * normalized contract has no `reactions` flag, so the capability is not
   * advertised. Threads are reported as `false` because Messenger has no
   * first-class thread concept on the Send API — conversations are
   * 1:1 / per-page.
   *
   * @returns The `ChannelFeatures` flags for Messenger.
   */
  listSupportedFeatures(): ChannelFeatures {
    return FEATURES
  }

  /**
   * Performs a single Send API call. Centralizes redaction so the page
   * access token never ends up in thrown error messages.
   *
   * @param body - JSON-serializable Send API request payload.
   * @returns The successful Send API response body.
   * @throws {Error} On non-2xx responses, error envelopes, or network
   *   failures. Errors quote a redacted method URL only.
   */
  private async callSendApi(body: unknown): Promise<MessengerSendApiResponse> {
    const url = `${this.apiBaseUrl}/${this.apiVersion}/me/messages?access_token=${this.pageAccessToken}`
    const safeUrl = `${this.apiBaseUrl}/${this.apiVersion}/me/messages?access_token=<redacted>`

    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' },
        body: JSON.stringify(body),
        signal: controller.signal,
      })
    } catch (error) {
      const original = error instanceof Error ? error.message : String(error)
      throw new Error(`Messenger Send API request failed (${safeUrl}): ${redact(original)}`, {
        cause: error,
      })
    } finally {
      clearTimeout(timer)
    }

    let parsed: {
      error?: { message?: string; code?: number }
      message_id?: string
      recipient_id?: string
    }
    try {
      parsed = (await response.json()) as typeof parsed
    } catch (error) {
      throw new Error(
        `Messenger Send API returned non-JSON response (${safeUrl}, HTTP ${response.status}).`,
        { cause: error },
      )
    }

    if (!response.ok || parsed.error) {
      const description = parsed.error?.message ?? `HTTP ${response.status}`
      throw new Error(`Messenger Send API call failed: ${redact(description)}`)
    }

    return parsed
  }
}

Functions

createProvider(config)

Convenience factory for the named-multi-provider bond pattern.

function createProvider(config?: MessengerConfig): MessengerChannelProvider
  • config — Optional Messenger config.

Returns: A new MessengerChannelProvider instance.

Constants

provider

Lazily-instantiated singleton instance for app-startup wiring. Reads configuration from environment variables on first use.

const provider: ChannelProvider

channelMessengerSecretDefinitions

Secret definitions required by the Messenger channel bond.

const channelMessengerSecretDefinitions: 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_MESSENGER_PAGE_ACCESS_TOKEN (required) — Messenger page access token
  • CHANNEL_MESSENGER_APP_SECRET (required) — Meta app secret

Runtime Dependencies

  • @molecule/api-channel

  • @molecule/api-secrets

  • Webhook subscription needs a GET echo the bond does not provide. When you register the webhook URL in the Meta console, Meta first sends GET ?hub.mode=subscribe&hub.verify_token=<your token>&hub.challenge=<n>. Your route must check the verify token you chose in the console and respond 200 with the raw hub.challenge value. Only POST deliveries go through verifyWebhookSignature() / parseInbound().

  • 24-hour messaging window: outside 24h since the user's last message, the Send API rejects standard sends — Meta requires an approved message tag for out-of-window messages. Expect and surface that API error.

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.