pipedog is an unofficial community port of Pipecat, not affiliated with Daily.
pipedog

WhatsApp

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), reject and terminate.
  • whatsAppWebhookHandler: a fetch handler for the webhook endpoint (GET verification and POST events). New in pipedog; upstream leaves this to your web framework.
  • parseWebhookRequest, verifyWebhookRequest, verifyWebhookSignature: the pieces on their own.

Installation

bun add @pipedog/transport-whatsapp @pipedog/transport-smallwebrtc
import { WhatsAppClient, whatsAppWebhookHandler } from "@pipedog/transport-whatsapp";

Runtimes

RuntimeWorksWhat it needs
Bun✅ yesA 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✅ yesA 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✅ yesA 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⚠️ partlyA 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 calls field. See Meta's calling docs.
  • A system-user access token (whatsappToken) and the phone number ID (phoneNumberId).
  • Your Meta app secret (whatsappSecret), so every webhook POST can be authenticated.
  • A verify token you choose, entered in the Meta dashboard, for the webhook's GET check.
  • 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 --whatsapp

It 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):

OptionTypeDefaultDescription
whatsappTokenstringrequiredGraph API access token.
phoneNumberIdstringrequiredThe business phone number ID.
whatsappSecretstring | nullnullThe app secret, to validate webhook signatures.
iceServersstring[] | IceServer[]Google's STUN serverICE servers for the calls' connections.
fetchtypeof fetchthe global fetchReplaces upstream's aiohttp session.
baseUrlstringhttps://graph.facebook.com/v23.0/The Graph API URL.
createConnection(iceServers) => SmallWebRTCConnectiona SmallWebRTCConnectionCreate 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

  1. The connect event arrives. The client creates a SmallWebRTCConnection and gives it the offer.
  2. It takes the SDP answer and removes every DTLS fingerprint but SHA-256, the only one WhatsApp accepts.
  3. It sends pre_accept, then accept, with that answer. Meta allows 30 to 60 seconds from the webhook. If either fails, the connection is disconnected and the error is raised.
  4. It calls your connectionCallback. The call ends on a terminate event, or when the connection closes.

Differences from upstream

  • fetch replaces aiohttp.ClientSession. Every request goes through the fetch option.
  • The models are interfaces with the wire's names, validated by parseWebhookRequest: call.from is upstream's from_, and the others stay sdp_type, start_time and 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.
  • updateWhatsappPhoneNumberId also 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).

On this page