@cool-ai/beach-transport-whatsapp
v0.2.0
Published
WhatsApp Cloud API transport adapters for Beach — webhook signature verification, payload parsing, and outbound /messages POST.
Readme
@cool-ai/beach-transport-whatsapp
Home: cool-ai.org · Documentation: cool-ai.org/docs
WhatsApp Cloud API transport adapters for Beach. Wire layer only — webhook signature verification, payload parsing, and outbound /messages POST. Channel-shaped concerns (envelope translation, the Missive part-bag, threading rules) belong above this in a future @cool-ai/beach-channel-whatsapp package; this one stops at the wire.
When to use this package directly
- You are writing a custom WhatsApp channel on top of a different missive shape than Beach's stock one.
- You want signature-verified inbound parsing without committing to the rest of Beach.
- You need to swap the formatter on the outbound adapter without taking the channel package.
If none of those match, wait for the channel package — it will bundle these adapters with the canonical envelope-to-payload translation.
Quickstart
import {
WhatsAppInboundAdapter,
WhatsAppOutboundAdapter,
} from '@cool-ai/beach-transport-whatsapp';
import express from 'express';
const inbound = new WhatsAppInboundAdapter({
config: {
appSecret: process.env.META_APP_SECRET!,
verifyToken: process.env.META_VERIFY_TOKEN!,
},
onMessage: async (parsed) => {
/* parsed is wire-shaped — translate to a Missive in your channel layer */
},
});
const app = express();
// IMPORTANT: do NOT mount express.json() ahead of the webhook — Meta signs
// the raw bytes; a re-serialised body will not verify.
app.all('/whatsapp/webhook', inbound.createWebhookHandler());
app.listen(3000);
const outbound = new WhatsAppOutboundAdapter({
phoneNumberId: process.env.META_PHONE_NUMBER_ID!,
appSecret: process.env.META_APP_SECRET!,
verifyToken: process.env.META_VERIFY_TOKEN!,
tokenProvider: async () => process.env.META_BEARER_TOKEN!,
});
await outbound.send({
messageType: 'text',
to: '447700900001',
body: 'Hello from Beach',
});Inbound — WhatsAppInboundAdapter
createWebhookHandler() returns a Node http request handler that the consumer mounts under their own routing, auth, and TLS termination — the same edge-only shape as createInspectHandler (CR-146). The adapter does not bind itself to Express.
The handler answers two flavours of request:
- GET — Meta's subscription handshake. When
hub.mode === 'subscribe'andhub.verify_tokenmatches the configured token, responds 200 with the value ofhub.challenge. Otherwise 403. - POST — webhook delivery. Reads the raw body, verifies
X-Hub-Signature-256againstappSecretin constant time, parses eachmessages[]entry into aParsedInboundWhatsApp, and invokesonMessagefor each. Always responds 200 once the signature has verified — Meta retries 5xx aggressively, and the consumer is expected to handle idempotency onmessageId.
Errors thrown from onMessage are caught and logged; the webhook still responds 200. The transport's job is "make sure Meta doesn't retry against verified content"; recovery from a downstream failure is the consumer's.
Supported inbound content kinds
ParsedInboundWhatsApp.content is a discriminated union covering text, image, video, audio (voice + non-voice), document, sticker, location, contacts, button-reply, list-reply, reaction. Unknown types are surfaced as { kind: 'unsupported', rawType } rather than dropped — the consumer decides whether to ignore or log.
Outbound — WhatsAppOutboundAdapter
Pure transport. Pass it the wire-shaped OutboundWhatsApp payload and the adapter:
- Validates Meta's documented field limits (button counts, title lengths, list-row totals) and throws synchronously if violated.
- Awaits
tokenProvider()so the bearer token is always current. Beach does not cache or refresh tokens — the provider is. The same callback shape is used by SMTP OAuth2 (CR-130). - POSTs to
https://graph.facebook.com/v<N>/<phoneNumberId>/messageswithAuthorization: Bearer <token>. - Returns
{ messageId }(Meta'swamid....) on success. - Throws
WhatsAppSendErroron non-2xx, carryingstatusand the response body so consumers can branch on transient vs permanent failures.
Supported outbound message types
| messageType | Meta API kind | Notes |
|-------------------------|------------------------|-------|
| text | text | body 1..4096 chars; optional previewUrl |
| image | image | mediaId xor link; optional caption |
| video | video | mediaId xor link; optional caption |
| audio | audio | mediaId xor link |
| document | document | mediaId xor link; optional filename, caption |
| interactive-buttons | interactive (button) | 1..3 buttons; titles 1..20 chars |
| interactive-list | interactive (list) | ≤ 10 rows total; row titles ≤ 24; descriptions ≤ 72 |
| reaction | reaction | messageId of parent + emoji (empty string clears) |
contextMessageId on any of the above renders Meta's quote-reply UI.
What this package does not do
- No template messages. WhatsApp's pre-approved template flow is a separate API surface; defer to a richer formatter when needed.
- No media upload. Meta requires a
media_idfrom a prior/mediaPOST when sendingmediaId-based messages. Upload is out of scope for v1; consumers either pass a publiclinkor call Meta's/mediaendpoint themselves and pass the resulting id. - No status callbacks (
sent/delivered/read). Meta delivers these in the same webhook payload alongsidemessages[]; this package surfaces only inbound user messages. Status handling lands as a follow-on if a consumer needs it. - No rate limiting. Meta enforces per-phone-number message rates. Consumers needing backpressure should wrap
send()themselves until a shared rate-limit primitive (CR-139) lands. - No Meta business-account management. Phone numbers, template approvals, and webhook URL registration are consumer-side configuration in the Meta dashboard.
- No channel layer. Translating a
ParsedInboundWhatsAppinto a BeachMissive(channelId, threadId, part-bag, the consumer'sonInbound) is the channel package's job.
Testing
- Unit tests in this repo cover signature verification, webhook parsing, the GET handshake, the POST flow (signed and unsigned), and outbound request shape for every supported message type. No network.
- Integration tests against the real Meta API are left to the consumer. The Cloud API exposes test phone numbers for this purpose.
Related
beach-transport-emailREADME — sibling transport package; shares the wire-vs-channel split pattern.beach-channel-emailREADME — what a channel layer on top of a transport looks like.
