Telnyx
Phone calls over Telnyx media streaming with TelnyxFrameSerializer, used with FastAPIWebsocketTransport.
Overview
TelnyxFrameSerializer speaks Telnyx's WebSocket media streaming protocol. Paired with
FastAPIWebsocketTransport, it lets a bot answer phone calls:
- Caller audio (PCMU or PCMA at 8 kHz) becomes
InputAudioRawFrames at the pipeline's input rate. - Bot audio is resampled to 8 kHz, encoded and sent as
mediaevents. - Touch-tone digits become
InputDTMFFrames. - An
EndFrameorCancelFramehangs up the call through Telnyx's REST API (autoHangUp).
Upstream page
Pipecat's documentation for the same serializer.
Source
packages/serializer-telnyx/src/telnyx.ts.
Telnyx docs
Voice API and media streaming.
Installation
bun add @pipedog/serializer-telnyx @pipedog/transport-websocketimport { TelnyxFrameSerializer } from "@pipedog/serializer-telnyx/telnyx";Prerequisites
- A Telnyx account, a phone number with voice, and media streaming pointed at your WebSocket endpoint.
- A Telnyx API key for auto hang-up. The examples read it from
TELNYX_API_KEY. - The stream ID and call control ID, which Telnyx sends in its
startmessage when the stream opens.
TelnyxFrameSerializer
Configuration
Prop
Type
InputParams
params is a TelnyxFrameSerializer.InputParams (TelnyxFrameSerializerInputParams):
| Param | Type | Default | Description |
|---|---|---|---|
telnyxSampleRate | number | 8000 | Sample rate Telnyx uses, in Hz. |
sampleRate | number | null | null | Override for the pipeline input rate. null uses the pipeline's. |
inboundEncoding | string | "PCMU" | Encoding of audio sent to Telnyx. Overwritten by the constructor's inboundEncoding. |
outboundEncoding | string | "PCMU" | Encoding of audio received from Telnyx. Overwritten by the constructor's outboundEncoding. |
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 Telnyx. |
Usage
Read Telnyx's first two messages to get the call details, then build the serializer and the transport:
import { TelnyxFrameSerializer } from "@pipedog/serializer-telnyx/telnyx";
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> {
// Telnyx sends a "connected" message, then "start" with the call details.
await connection.recv();
const start = JSON.parse(String(await connection.recv()));
const serializer = new TelnyxFrameSerializer({
streamId: start.stream_id,
callControlId: start.start.call_control_id,
outboundEncoding: start.start.media_format.encoding,
inboundEncoding: "PCMU",
apiKey: process.env.TELNYX_API_KEY ?? "",
});
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 WebSocket URL you give Telnyx's media streaming points here.
const handler = websocketFetchHandler(runCall);Without auto hang-up, the call control ID and API key are not needed:
const noHangUp = new TelnyxFrameSerializer({
streamId: "stream-id",
outboundEncoding: "PCMU",
inboundEncoding: "PCMU",
params: new TelnyxFrameSerializer.InputParams({ autoHangUp: false }),
});Notes
- With
autoHangUpon (the default), the constructor throws ifcallControlIdorapiKeyis missing. The hang-up is aPOSTtohttps://api.telnyx.com/v2/calls/{callControlId}/actions/hangup, attempted once. A 422 with error code 90018 (call already ended) is logged at debug level; other failures are logged as errors and don't throw. - Only
PCMUandPCMAare supported. Another encoding throws when the first audio is sent or received. - An
InterruptionFramesends aclearevent, so Telnyx drops the audio it has buffered. OutputTransportMessageFrames are sent as JSON, except RTVI messages whileignoreRtviMessagesis on.- Set
addWavHeader: falseon the transport params: Telnyx takes raw encoded audio. - A DTMF event whose digit is not a keypad key is ignored.
TeXML helpers
@pipedog/serializer-telnyx/texml holds the HTTP side of a Telnyx bot: answering the call,
checking webhooks, and placing outbound calls. Not in upstream (Pipecat leaves it to the app); it
only uses fetch and WebCrypto, so it runs on Bun, Node, Deno and Workers.
import {
answerTexml,
parseCallCost,
parseStatusCallback,
TelnyxTexmlClient,
verifyTelnyxSignature,
} from "@pipedog/serializer-telnyx/texml";
// The TeXML application's voice URL: connect the call to the bot's WebSocket.
async function answer(request: Request): Promise<Response> {
const body = await request.text();
const ok = await verifyTelnyxSignature({
body,
signature: request.headers.get("telnyx-signature-ed25519"),
timestamp: request.headers.get("telnyx-timestamp"),
publicKey: env.TELNYX_PUBLIC_KEY, // Mission Control → Keys & Credentials
});
if (!ok) return new Response("bad signature", { status: 401 });
return new Response(answerTexml("wss://bot.example.com/telnyx", { parameters: { lang: "en" } }), {
headers: { "content-type": "text/xml" },
});
}
// Outbound calls through the TeXML REST API.
const telnyx = new TelnyxTexmlClient({
apiKey: env.TELNYX_API_KEY,
accountSid: env.TELNYX_ACCOUNT_SID,
applicationSid: env.TELNYX_APPLICATION_SID,
from: "+15550000000",
});
const { callSid } = await telnyx.dial({
to: "+15551234567",
url: "https://bot.example.com/answer",
statusCallback: "https://bot.example.com/status",
});verifyTelnyxSignature()checks the Ed25519 signature over`${timestamp}|${body}`and refuses timestamps more than 5 minutes off (tolerance). Pass the raw body, before parsing it.answerTexml(url, { parameters, codec, track })returns<Connect><Stream bidirectionalMode="rtp">, with the URL and parameters XML-escaped. Parameters reach the bot in thestartmessage'scustom_parameters.parseStatusCallback(body)reads a form-encoded status callback (callSid,status, allfields), andparseCallCost(body)Telnyx'scall.costwebhook (total, currency, billed seconds and cost by part). Both returnnullfor anything else.TelnyxTexmlClient.dial()throwsTelnyxCallRejectedErrorwhen Telnyx refuses the call (a 4xx, or a destination that is not E.164); itsinvalidDestinationsays whether the number itself is unusable, so a retry won't help. A 5xx throws a plainError, since the call may have been placed anyway.getCall(callSid)looks a call up. Passfetchto the client for tests or a proxy.
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-telnyx/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. On Workers, accept the Telnyx WebSocket with websocketFetchHandler; see
WebSocket.
Resampling between 8 kHz and the pipeline rate goes through core's resampler engine
(@pipedog/resampler-wasm by default, a pure TypeScript fallback if it can't load) in place of
libsoxr, so the samples differ slightly from upstream's.
parseTelephonyWebsocket from @pipedog/runner detects Telnyx and parses its handshake for you
(callData.streamId, callData.callId for the call control ID, and outboundEncoding); see
How phone calls connect. The example above reads the
start message by hand instead.
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).