# Telnyx

> Phone calls over Telnyx media streaming with TelnyxFrameSerializer, used with FastAPIWebsocketTransport.

Source: https://polvallverdu.github.io/pipedog/docs/serializers/telnyx/



## Overview [#overview]

**`TelnyxFrameSerializer`** speaks Telnyx's WebSocket media streaming protocol. Paired with
[`FastAPIWebsocketTransport`](https://polvallverdu.github.io/pipedog/docs/transports/websocket.md), it lets a bot answer phone calls:

* Caller audio (PCMU or PCMA at 8 kHz) becomes `InputAudioRawFrame`s at the pipeline's input rate.
* Bot audio is resampled to 8 kHz, encoded and sent as `media` events.
* Touch-tone digits become `InputDTMFFrame`s.
* An `EndFrame` or `CancelFrame` hangs up the call through Telnyx's REST API (`autoHangUp`).

<Cards>
  <Card title="Upstream page" href="https://docs.pipecat.ai/api-reference/server/services/serializers/telnyx">
    Pipecat's documentation for the same serializer.
  </Card>

  <Card title="Source" href="https://github.com/polvallverdu/pipedog/tree/main/packages/serializer-telnyx/src">
    `packages/serializer-telnyx/src/telnyx.ts`.
  </Card>

  <Card title="Telnyx docs" href="https://developers.telnyx.com/docs/voice/programmable-voice/voice-api-fundamentals">
    Voice API and media streaming.
  </Card>
</Cards>

## Installation [#installation]

```bash
bun add @pipedog/serializer-telnyx @pipedog/transport-websocket
```

```ts
import { TelnyxFrameSerializer } from "@pipedog/serializer-telnyx/telnyx";
```

## Prerequisites [#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 [#telnyxframeserializer]

### Configuration [#configuration]

| Option | Type | Required | Description |
|---|---|---|---|
| `streamId` | `string` | yes | The Stream ID for Telnyx. |
| `outboundEncoding` | `string` | yes | The encoding type for outbound audio (e.g., "PCMU"). |
| `inboundEncoding` | `string` | yes | The encoding type for inbound audio (e.g., "PCMU"). |
| `callControlId` | `string \| null \| undefined` | no | The Call Control ID for the Telnyx call (optional, but required for auto hang-up). |
| `apiKey` | `string \| null \| undefined` | no | Your Telnyx API key (required for auto hang-up). |
| `params` | `TelnyxFrameSerializerInputParams \| null \| undefined` | no | Configuration parameters. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |

### InputParams [#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 [#usage]

Read Telnyx's first two messages to get the call details, then build the serializer and the
transport:

```ts title="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:

```ts
const noHangUp = new TelnyxFrameSerializer({
  streamId: "stream-id",
  outboundEncoding: "PCMU",
  inboundEncoding: "PCMU",
  params: new TelnyxFrameSerializer.InputParams({ autoHangUp: false }),
});
```

### Notes [#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.
* `OutputTransportMessageFrame`s 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 [#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.

```ts
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 [#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](https://polvallverdu.github.io/pipedog/docs/transports/websocket.md).

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](https://polvallverdu.github.io/pipedog/docs/telephony/#reading-the-handshake). The example above reads the
`start` message by hand instead.
