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

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 media events.
  • Touch-tone digits become InputDTMFFrames.
  • An EndFrame or CancelFrame hangs up the call through Telnyx's REST API (autoHangUp).

Installation

bun add @pipedog/serializer-telnyx @pipedog/transport-websocket
import { 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 start message when the stream opens.

TelnyxFrameSerializer

Configuration

Prop

Type

InputParams

params is a TelnyxFrameSerializer.InputParams (TelnyxFrameSerializerInputParams):

ParamTypeDefaultDescription
telnyxSampleRatenumber8000Sample rate Telnyx uses, in Hz.
sampleRatenumber | nullnullOverride for the pipeline input rate. null uses the pipeline's.
inboundEncodingstring"PCMU"Encoding of audio sent to Telnyx. Overwritten by the constructor's inboundEncoding.
outboundEncodingstring"PCMU"Encoding of audio received from Telnyx. Overwritten by the constructor's outboundEncoding.
autoHangUpbooleantrueHang up the call on EndFrame or CancelFrame.
resamplerClearAfterSecsnumber | null0.2Seconds of silence after which the stream resamplers clear their history. null never clears.
ignoreRtviMessagesbooleantrueDrop 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:

telnyx.ts
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 autoHangUp on (the default), the constructor throws if callControlId or apiKey is missing. The hang-up is a POST to https://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 PCMU and PCMA are supported. Another encoding throws when the first audio is sent or received.
  • An InterruptionFrame sends a clear event, so Telnyx drops the audio it has buffered.
  • OutputTransportMessageFrames are sent as JSON, except RTVI messages while ignoreRtviMessages is on.
  • Set addWavHeader: false on 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 the start message's custom_parameters.
  • parseStatusCallback(body) reads a form-encoded status callback (callSid, status, all fields), and parseCallCost(body) Telnyx's call.cost webhook (total, currency, billed seconds and cost by part). Both return null for anything else.
  • TelnyxTexmlClient.dial() throws TelnyxCallRejectedError when Telnyx refuses the call (a 4xx, or a destination that is not E.164); its invalidDestination says whether the number itself is unusable, so a retry won't help. A 5xx throws a plain Error, since the call may have been placed anyway. getCall(callSid) looks a call up. Pass fetch to the client for tests or a proxy.

Runtime notes

RuntimeWorksWhat it needs
Bun✅ yesNothing.
Node.js✅ yesNothing.
Deno✅ yesNothing.
Cloudflare Workers✅ yesNothing.

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).

On this page