Twilio
Phone calls over Twilio Media Streams with TwilioFrameSerializer, used with FastAPIWebsocketTransport.
Overview
TwilioFrameSerializer speaks Twilio's Media Streams WebSocket protocol. Paired with
FastAPIWebsocketTransport, it lets a bot answer phone calls:
- Caller audio (μ-law at 8 kHz) becomes
InputAudioRawFrames at the pipeline's input rate. - Bot audio is resampled to 8 kHz, μ-law encoded and sent as
mediaevents. - Touch-tone digits become
InputDTMFFrames. - An
InterruptionFramesendsclear, so Twilio drops the audio it has buffered. - An
EndFrameorCancelFramehangs up the call through Twilio's REST API (autoHangUp).
New to phone bots? Start with How phone calls connect and the Twilio quickstart.
Upstream page
Pipecat's documentation for the same serializer.
Source
packages/serializer-twilio/src/twilio.ts.
Twilio docs
Media Streams WebSocket messages.
Installation
bun add @pipedog/serializer-twilio @pipedog/transport-websocket @pipedog/runnerimport { TwilioFrameSerializer } from "@pipedog/serializer-twilio/twilio";Prerequisites
- A Twilio account and a phone number with voice, whose incoming-call webhook returns TwiML with
<Connect><Stream url="wss://your.host/ws" />. - For auto hang-up, your account SID and one credential: the auth token, or an API key (its SID,
which starts with
SK, and its secret). The examples read them fromTWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_API_KEYandTWILIO_API_SECRET. Each Twilio region has its own auth token and API keys, so use the ones that belong to the region you hang up through. - The stream SID and call SID, which Twilio sends in its
startmessage.parseTelephonyWebsocketreads them for you.
TwilioFrameSerializer
Configuration
Prop
Type
InputParams
params is a TwilioFrameSerializer.InputParams (TwilioFrameSerializerInputParams):
| Param | Type | Default | Description |
|---|---|---|---|
twilioSampleRate | number | 8000 | Sample rate Twilio uses, in Hz. |
sampleRate | number | null | null | Override for the pipeline input rate. null uses the pipeline's. |
autoHangUp | boolean | true | Hang up the call on EndFrame or CancelFrame. |
resamplerClearAfterSecs | number | null | 0.2 | Seconds of silence after which the stream resamplers clear their history. null never clears. |
ignoreRtviMessages | boolean | true | Drop RTVI messages instead of sending them to Twilio. |
Usage
Read Twilio's handshake to get the call details, then build the serializer and the transport:
import { parseTelephonyWebsocket } from "@pipedog/runner/utils";
import { TwilioFrameSerializer } from "@pipedog/serializer-twilio/twilio";
import {
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
} from "@pipedog/transport-websocket/fastapi";
import { websocketFetchHandler } from "@pipedog/transport-websocket/fetch";
import type { WebsocketConnection } from "@pipedog/transport-websocket/socket";
async function runCall(connection: WebsocketConnection): Promise<void> {
// Twilio sends "connected", then "start" with the stream and call SIDs.
const [, callData] = await parseTelephonyWebsocket(connection);
const serializer = new TwilioFrameSerializer({
streamSid: callData.streamId ?? "",
callSid: callData.callId,
accountSid: process.env.TWILIO_ACCOUNT_SID,
authToken: process.env.TWILIO_AUTH_TOKEN,
});
const transport = new FastAPIWebsocketTransport(
connection,
new FastAPIWebsocketParams({
audioInEnabled: true,
audioOutEnabled: true,
addWavHeader: false,
serializer,
}),
);
// ... build and run the pipeline with transport.input() and transport.output()
}
// The <Stream url="wss://..."> in your TwiML points here.
const handler = websocketFetchHandler(runCall);To hang up with an API key instead of the auth token, pass apiKeySid and apiKeySecret:
const withApiKey = new TwilioFrameSerializer({
streamSid: "MZ...",
callSid: "CA...",
accountSid: process.env.TWILIO_ACCOUNT_SID,
// An API key replaces the auth token. Pass both parts, and no authToken.
apiKeySid: process.env.TWILIO_API_KEY,
apiKeySecret: process.env.TWILIO_API_SECRET,
});To hang up through a regional API host, pass both region and edge:
const regional = new TwilioFrameSerializer({
streamSid: "MZ...",
callSid: "CA...",
accountSid: "AC...",
authToken: "...",
// Hang up through api.dublin.ie1.twilio.com. Both are required together.
region: "ie1",
edge: "dublin",
});Without auto hang-up, only the stream SID is needed:
const noHangUp = new TwilioFrameSerializer({
streamSid: "MZ...",
params: new TwilioFrameSerializer.InputParams({ autoHangUp: false }),
});Notes
- With
autoHangUpon (the default), the constructor throws ifcallSidoraccountSidis missing, if there is neither anauthTokennor an API key, if both anauthTokenand any part of an API key are given, if only one ofapiKeySidandapiKeySecretis set, or if only one ofregionandedgeis set (unlessbaseUrlis). - The hang-up is a
POSTofStatus=completedtohttps://api.twilio.com/2010-04-01/Accounts/{accountSid}/Calls/{callSid}.jsonwith Basic auth (accountSidandauthToken, orapiKeySidandapiKeySecret), attempted once. A 404 with code 20404 (call already ended) is logged at debug level; other failures are logged as errors and don't throw.baseUrlreplaces the host, for a Twilio-compatible API. - When the caller hangs up first, the socket is already closed, so no hang-up request is made.
- Set
addWavHeader: falseon the transport params: Twilio takes raw μ-law. OutputTransportMessageFrames are sent as JSON, except RTVI messages whileignoreRtviMessagesis on.- A DTMF event whose digit is not a keypad key is ignored. Malformed JSON and
mediaevents without a payload are ignored too.
Runtime notes
| Runtime | Works | What it needs |
|---|---|---|
| Bun | ✅ yes | Nothing. |
| Node.js | ✅ yes | Nothing. |
| Deno | ✅ yes | Nothing. |
| Cloudflare Workers | ✅ yes | Nothing. |
Generated from pipecat.runtimes and pipecat.compat in packages/serializer-twilio/package.json: ✅ means the compat smoke test passes on that runtime.
The serializer uses the global fetch for the hang-up and runs on Bun, Node, Deno and Cloudflare
Workers. Accept the Twilio WebSocket with websocketFetchHandler (Deno, Workers) or any of the
WebSocket server adapters.
Resampling between 8 kHz and the pipeline rate goes through core's resampler engine
(@pipedog/resampler-wasm by default) in place of libsoxr, so resampled samples differ slightly
from upstream's. At 8 kHz the wire output is byte-identical; the package's golden tests replay
vectors recorded from upstream.
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).