WhatsApp Business voice calls with WhatsAppClient, over SmallWebRTC. Handles Meta's calling webhooks, signature checks, and accepting, rejecting and ending calls.
Overview
A user calls your WhatsApp Business number. Meta's Cloud API sends your webhook a connect event
with an SDP offer. WhatsAppClient verifies the request, answers the offer over a
SmallWebRTC connection (it pre-accepts, then accepts the call
through the Graph API), and hands you the connection. You run a pipeline on it with
SmallWebRTCTransport, exactly as for a browser client. This is a port of Pipecat's
pipecat.transports.whatsapp.
WhatsAppClient: handles webhooks and calls, and keeps track of the ongoing calls.WhatsAppApi: the Graph API calls: answer (pre_accept,accept),rejectandterminate.whatsAppWebhookHandler: a fetch handler for the webhook endpoint (GETverification andPOSTevents). New in pipedog; upstream leaves this to your web framework.parseWebhookRequest,verifyWebhookRequest,verifyWebhookSignature: the pieces on their own.
Upstream page
Pipecat's documentation for the same transport.
Source
client.ts, api.ts and http.ts.
Meta's calling docs
The WhatsApp Business Calling API: webhooks and call actions.
Installation
bun add @pipedog/transport-whatsapp @pipedog/transport-smallwebrtcimport { WhatsAppClient, whatsAppWebhookHandler } from "@pipedog/transport-whatsapp";Runtimes
| Runtime | Works | What it needs |
|---|---|---|
| Bun | ✅ yes | A WhatsApp Business account with Calling enabled, an access token and a public HTTPS webhook URL. A long-lived server that can use UDP (SmallWebRTC runs on werift). |
| Node.js | ✅ yes | A WhatsApp Business account with Calling enabled, an access token and a public HTTPS webhook URL. A long-lived server that can use UDP (SmallWebRTC runs on werift). |
| Deno | ✅ yes | A WhatsApp Business account with Calling enabled, an access token and a public HTTPS webhook URL. A long-lived server that can use UDP (SmallWebRTC runs on werift). |
| Cloudflare Workers | ⚠️ partly | A WhatsApp Business account with Calling enabled, an access token and a public HTTPS webhook URL. Webhook verification, signature checks and API calls only. There is no WebRTC, so calls cannot be answered. |
Generated from pipecat.runtimes and pipecat.compat in packages/transport-whatsapp/package.json: ✅ means the compat smoke test passes on that runtime.
Answering calls needs WebRTC, which runs on werift and needs UDP sockets: Bun, Node and Deno only.
On Cloudflare Workers only the webhook parts work: Meta's GET verification, the
X-Hub-Signature-256 check (HMAC through WebCrypto) and the Graph API calls (rejectCallToWhatsapp,
terminateCallToWhatsapp). A connect event there fails with an error that says Workers have no
WebRTC. The package's ./connection export resolves to a stub under the "workerd" condition, so
werift is never bundled into a Worker.
Prerequisites
- A WhatsApp Business phone number with Calling enabled, and a webhook subscribed to the
callsfield. See Meta's calling docs. - A system-user access token (
whatsappToken) and the phone number ID (phoneNumberId). - Your Meta app secret (
whatsappSecret), so every webhookPOSTcan be authenticated. - A verify token you choose, entered in the Meta dashboard, for the webhook's
GETcheck. - A public HTTPS URL for the webhook, and a server that can use UDP for the media.
Usage
import { TransportParams } from "@pipedog/core/transports/base_transport";
import { SmallWebRTCTransport } from "@pipedog/transport-smallwebrtc/transport";
import { WhatsAppClient, whatsAppWebhookHandler } from "@pipedog/transport-whatsapp";
const client = new WhatsAppClient({
whatsappToken: process.env.WHATSAPP_TOKEN!,
phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
whatsappSecret: process.env.WHATSAPP_APP_SECRET, // checks X-Hub-Signature-256
});
const webhook = whatsAppWebhookHandler(client, {
verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
connectionCallback: async (connection, call) => {
const transport = new SmallWebRTCTransport(
connection,
new TransportParams({ audioInEnabled: true, audioOutEnabled: true }),
);
void runBot(transport, call.from); // your pipeline
},
});
Bun.serve({ port: 8080, routes: { "/webhook": { GET: webhook, POST: webhook } } });The callback gets the SmallWebRTCConnection and the connect event, with the caller's number
(call.from) and the call ID (call.id). If it throws, the call stays up and the error is logged.
With the runner
@pipedog/runner wires all of this for you, as upstream's runner does. Install
@pipedog/transport-whatsapp, set WHATSAPP_APP_SECRET, WHATSAPP_PHONE_NUMBER_ID,
WHATSAPP_TOKEN and WHATSAPP_WEBHOOK_VERIFICATION_TOKEN, and run:
bun bot.ts -t webrtc --whatsappIt serves GET /whatsapp (Meta's verification) and POST /whatsapp (call events). Each call starts
your bot(runnerArgs) with SmallWebRTCRunnerArguments: build the transport with
createTransport() as for any WebRTC session, and read the caller from runnerArgs.body
(from, id). See the runner.
Without the helper
// GET: Meta checks the endpoint
const challenge = await client.handleVerifyWebhookRequest(params, verifyToken); // the number to send back
// POST: an event. Pass the raw body: the signature is over the exact bytes.
const raw = await request.text();
await client.handleWebhookRequest(parseWebhookRequest(JSON.parse(raw)), {
connectionCallback,
rawBody: raw,
sha256Signature: request.headers.get("x-hub-signature-256"),
});With a whatsappSecret, a request without a matching signature is rejected. Without one nothing is
checked, as upstream does.
Configuration
new WhatsAppClient(options):
| Option | Type | Default | Description |
|---|---|---|---|
whatsappToken | string | required | Graph API access token. |
phoneNumberId | string | required | The business phone number ID. |
whatsappSecret | string | null | null | The app secret, to validate webhook signatures. |
iceServers | string[] | IceServer[] | Google's STUN server | ICE servers for the calls' connections. |
fetch | typeof fetch | the global fetch | Replaces upstream's aiohttp session. |
baseUrl | string | https://graph.facebook.com/v23.0/ | The Graph API URL. |
createConnection | (iceServers) => SmallWebRTCConnection | a SmallWebRTCConnection | Create the call's connection yourself. |
Methods: handleWebhookRequest, handleVerifyWebhookRequest, terminateAllCalls, and the setters
updateIceServers, updateWhatsappSecret, updateWhatsappToken and updateWhatsappPhoneNumberId.
terminateAllCalls() ends every active call through the API and disconnects its connection.
How a call is answered
- The
connectevent arrives. The client creates aSmallWebRTCConnectionand gives it the offer. - It takes the SDP answer and removes every DTLS fingerprint but SHA-256, the only one WhatsApp accepts.
- It sends
pre_accept, thenaccept, with that answer. Meta allows 30 to 60 seconds from the webhook. If either fails, the connection is disconnected and the error is raised. - It calls your
connectionCallback. The call ends on aterminateevent, or when the connection closes.
Differences from upstream
fetchreplacesaiohttp.ClientSession. Every request goes through thefetchoption.- The models are interfaces with the wire's names, validated by
parseWebhookRequest:call.fromis upstream'sfrom_, and the others staysdp_type,start_timeand so on. It also accepts the optional contact fields Meta's current docs describe. - The signature check is asynchronous, because HMAC uses WebCrypto.
- The deprecated one-argument
connectionCallback(connection)form is not ported. updateWhatsappPhoneNumberIdalso updates the calls URL, where upstream only stores the value.
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).