@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-messengernpm · 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.tsJSDoc, 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- Setup: Create a Meta app with the Messenger product and generate a Page access token for your page.
- Get it here: https://developers.facebook.com/apps
CHANNEL_MESSENGER_APP_SECRET(required) — Meta app secret- Setup: Your Meta app → App settings → Basic → App secret.
- Get it here: https://developers.facebook.com/apps
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 respond200with the rawhub.challengevalue. Only POST deliveries go throughverifyWebhookSignature()/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_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.