---
title: "Telnyx"
description: "Send and receive SMS and MMS through the Telnyx Messaging API v2 with the SMS SDK telnyx() adapter: setup, senders, webhooks, errors, and limits."
---

The `telnyx()` adapter sends SMS and MMS through the Telnyx Messaging API v2 and parses Telnyx's Ed25519-signed messaging webhooks.

To compare providers, see the [Providers overview](/providers/overview).

## Support status

**Supported.** Request fields, responses, the error catalog, and Ed25519 webhook signing follow Telnyx's official documentation and its official Node SDK source. The adapter passes the shared contract tests.

## Installation

```package-install
npm install @opencoredev/sms-sdk
```

```ts
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
```

## Environment variables

| Variable | Required | Used for |
| --- | --- | --- |
| `TELNYX_API_KEY` | Yes | API v2 key, sent as a Bearer token. |
| `TELNYX_FROM` | Unless you send from a profile | Default sender: a Telnyx number, short code, or alphanumeric sender ID. |
| `TELNYX_MESSAGING_PROFILE_ID` | For alphanumeric senders | Messaging profile ID sent with every message. |
| `TELNYX_PUBLIC_KEY` | For webhooks | Base64 Ed25519 public key from Mission Control, Keys & Credentials, Public Key. |

The examples and the doctor CLI use these names. The adapter itself reads no environment variables. Keep the API key on the server.

## Basic usage

```ts
import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";

const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100001" })],
});

const result = await sms.send({ to: "+14155550123", body: "Your order has shipped." });
console.log(result.providerId); // the Telnyx message ID (data.id)
```

`telnyx()` throws `ConfigurationError` at startup when `apiKey` is empty.

## Authentication

Each request carries an API v2 key as `Authorization: Bearer <apiKey>`. Create keys in Mission Control under API Keys. Webhooks are verified with a separate public key, not the API key.

## Configuration

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `apiKey` | `string` | required | API v2 key. |
| `from` | `SmsFrom` | none | Default sender. `{ messagingService: profileId }` sends from the profile's number pool. |
| `messagingProfileId` | `string` | none | Sent as `messaging_profile_id` with every message. Required for alphanumeric senders. |
| `baseUrl` | `string` | `https://api.telnyx.com` | API origin, for a proxy or a mock server. |
| `fetch` | `FetchLike` | global `fetch` | Custom fetch. |

## API version and endpoint

- `POST https://api.telnyx.com/v2/messages`
- `Authorization: Bearer <apiKey>`, JSON body.
- Fields sent: `to`, `from` or `messaging_profile_id`, `text`, `media_urls` with `type: "MMS"`, `webhook_url` (from `webhookUrl`), `send_at` (from `sendAt`), and any [provider options](#provider-options).
- Success: a 2xx response with `data.id`. Delivery comes from `data.to[0].status`, usually `queued`. A 2xx without `data.id` is an unknown outcome.

## Senders

| Sender | Write | Sent as |
| --- | --- | --- |
| Long code or toll-free number | `"+15550100001"` | `from` |
| Short code | `{ shortCode: "12345" }` | `from` |
| Alphanumeric sender ID | `{ senderId: "Acme" }` | `from`, plus `messaging_profile_id` from the adapter |
| Messaging profile number pool | `{ messagingService: "<profile id>" }` | `messaging_profile_id`, with no `from` |

An alphanumeric sender without `messagingProfileId` on the adapter fails local validation before any request. In Telnyx, assign each number to a messaging profile before it sends.

## Regions and countries

Telnyx documents these rules in [Choosing a sender type](https://developers.telnyx.com/docs/messaging/getting-started/choosing-your-sender-type):

- 10DLC brand and campaign registration is required for A2P messaging to US mobile numbers.
- Toll-free numbers need verification, and they work for both US and Canada destinations.
- Short codes need carrier approval and work in one country. A US short code does not reach Canada.
- [Alphanumeric senders](https://developers.telnyx.com/docs/messaging/messages/alphanumeric-sender-id) can't send to the US, Canada, or Puerto Rico. They are one-way, so recipients can't reply.

Some countries, such as the UK and France, require alphanumeric sender IDs to be registered in advance. Telnyx's [international SMS compliance guide](https://developers.telnyx.com/docs/messaging/messages/international-sms-compliance) covers country rules.

SMS SDK does not check these rules before sending, and they have not been tested against live accounts. See [Sender registration and consent](/guides/sender-registration-and-consent).

## Capabilities

| Capability | Telnyx adapter |
| --- | --- |
| MMS | Yes |
| Scheduling | Yes, any sender |
| Validity period | No. Telnyx's request has no such field. |
| Per-message `webhookUrl` | Yes |
| Delivery status webhooks | Yes |
| Inbound webhooks | Yes |
| Native idempotency | No |

## Provider options

Pass Telnyx's other `POST /v2/messages` fields in `providerOptions.telnyx`:

```ts
import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";

const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100001" })],
});

await sms.send({
  to: "+14155550123",
  body: "Your order has shipped.",
  webhookUrl: "https://example.com/webhooks/sms/telnyx",
  providerOptions: {
    telnyx: { encoding: "gsm7", webhookFailoverUrl: "https://backup.example.com/webhooks/sms/telnyx" },
  },
});
```

| Option | Telnyx field | Type |
| --- | --- | --- |
| `subject` | `subject` | `string`, the subject of an MMS |
| `webhookFailoverUrl` | `webhook_failover_url` | `http` or `https` URL |
| `useProfileWebhooks` | `use_profile_webhooks` | `boolean`, Telnyx default `true` |
| `autoDetect` | `auto_detect` | `boolean`, Telnyx default `false` |
| `encoding` | `encoding` | `"auto" \| "gsm7" \| "ucs2"`. With `"gsm7"`, Telnyx answers 400 if a character has no GSM-7 form. |
| `extra` | any other field | `Record<string, string \| number \| boolean>`, sent as is |

Unknown keys, wrong types, and `extra` keys that name a field SMS SDK sets throw before any request. The options go out only when the `telnyx` adapter sends. See [`providerOptions`](/reference/send#provideroptions) for fallback and idempotency.

SMS SDK sets `to`, `from`, `messaging_profile_id`, `text`, `media_urls`, `type`, `webhook_url`, and `send_at` itself, so `extra` cannot.

## Example: send and track delivery

```ts send-with-status.ts
import { createSmsClient, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";

const from = process.env.TELNYX_FROM;
const to = process.env.SMS_EXAMPLE_TO;
if (!isE164(from) || !isE164(to)) {
  throw new Error("Set TELNYX_FROM and SMS_EXAMPLE_TO to E.164 numbers.");
}

const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from })],
});

try {
  const result = await sms.send({
    to,
    body: "Your order has shipped.",
    idempotencyKey: "order:123:shipped:v1",
    webhookUrl: "https://example.com/webhooks/sms/telnyx",
  });
  console.log(`Telnyx accepted ${result.providerId} (${result.delivery})`);
} catch (error) {
  if (isSmsError(error)) console.error(error.toJSON());
  throw error;
}
```

You see `Telnyx accepted <uuid> (queued)`. Acceptance is not delivery. Telnyx posts `message.sent` and `message.finalized` events to the `webhookUrl`, covered in [Delivery status webhooks](/receiving/delivery-status-webhooks).

## Delivery and inbound webhooks

- **Status:** pass `webhookUrl` per message, or set the webhook URL on the messaging profile.
- **Inbound:** set the inbound webhook URL on the messaging profile the number belongs to.

```ts
import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";

export async function POST(request: Request): Promise<Response> {
  try {
    const event = await parseSmsWebhook({
      provider: "telnyx",
      request,
      credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
    });
    if (event.type === "message.delivered" || event.type === "message.undelivered") {
      await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
    } else if (event.type === "recipient.opted_out") {
      await db.suppressions.add(event.from);
    }
    return new Response(null, { status: 200 });
  } catch (error) {
    if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
    throw error;
  }
}
```

SMS SDK maps Telnyx's `message.received`, `message.sent`, and `message.finalized` events. Status comes from `data.payload.to[0].status`: `queued` and `sending` become `message.queued`; `sent` and `delivery_unconfirmed` become `message.sent`; `delivered` becomes `message.delivered`; `sending_failed`, `delivery_failed`, and `expired` become `message.undelivered`, or `message.filtered` with error 40002, 40003, or 40322. Other event types and statuses, such as `read`, become `unrecognized`.

`dedupeKey` is `telnyx:{data.id}`, the webhook event ID. `providerId` is the message ID, `data.payload.id`, which matches `result.providerId`.

Telnyx sends no opt-out flag on inbound messages, so STOP and HELP come from keyword detection, with `source: "keyword"`.

## Signature verification

Telnyx signs each webhook with two headers, `telnyx-timestamp` in Unix seconds and `telnyx-signature-ed25519` in base64. The signature is Ed25519 over `` `${timestamp}|${rawBody}` ``. SMS SDK verifies it with your account's public key through Web Crypto.

- A timestamp more than 300 seconds before or after now fails with `stale_timestamp`. Change the window with `toleranceSec`.
- The URL is not signed, so proxies do not affect verification and there is no `publicUrl` option.
- A public key that is not 32 bytes of base64 fails with `invalid_credentials`.

## Error mapping

| Telnyx response | Category | Falls back |
| --- | --- | --- |
| 10009, 10010, 20001, 20002, 20003, 20006, 20008; 401 or 403 without a listed code | `auth` | Yes |
| 10011, 40318 (queue full) | `rate_limited` | Yes, after retries |
| 40305, 40306, 40308, 40315, 40320, 40321, 40329, 40330 | `sender` | Yes |
| 20013, 20100, 40309, 40312, 40314, 40331, 40333 (spend limit) | `account` | Yes |
| 40301, 40310, 40319 | `recipient` | No |
| 40300 (blocked due to STOP), 40322 (blocked content) | `compliance` | Never |
| Any other 4xx | `request` | No |
| 429 without code 10011 or 40318 | unknown | Never |
| 5xx, network error, timeout, 2xx without `data.id` | unknown | Never |

The code comes from `errors[0].code` in the response. Telnyx does not document a `Retry-After` header, but SMS SDK honors one if present.

## Limitations

- No `validityPeriodSec`. For an expiry, use Twilio, Plivo, or Vonage.
- Alphanumeric senders need a messaging profile ID on the adapter.
- Error 40008 is not mapped to `message.filtered`, because Telnyx's pages disagree on its meaning. It stays `message.undelivered`.

## Testing

Point `telnyx()` at a fake API with `mockFetch`:

```ts
import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
import { mockFetch } from "@opencoredev/sms-sdk/testing";

const fake = mockFetch({
  status: 200,
  body: { data: { id: "40385f64-5717-4562-b3fc-2c963f66afa6", to: [{ phone_number: "+14155550123", status: "queued" }] } },
});
const sms = createSmsClient({ adapters: [telnyx({ apiKey: "test", from: "+15550100001", fetch: fake.fetch })] });

const result = await sms.send({ to: "+14155550123", body: "Hi" });
console.log(result.providerId, JSON.parse(fake.calls[0]?.body ?? "{}")); // the id, and the JSON request body
```

Test your webhook handler with real signatures from `generateTelnyxKeyPair()` and `signedTelnyxRequest()`. See [Local testing](/guides/local-testing#test-webhook-handlers).

The adapter's own tests live in the SMS SDK repository. From `packages/sms-sdk`:

```bash
bun test test/contracts/telnyx.test.ts test/webhooks/telnyx.test.ts
```

The live test sends one real, billable message to each configured provider. It is skipped unless you opt in:

```bash
LIVE_SMS_TESTS=true LIVE_SMS_TO=+14155550123 LIVE_SMS_ALLOWLIST=+14155550123 \
TELNYX_API_KEY=... TELNYX_FROM=+15550100001 \
bun test test/integration/live.test.ts
```

Check your configuration first with `npx @opencoredev/sms-sdk doctor --adapter telnyx`.

## API reference

```ts ignore="signature listing"
telnyx(options: TelnyxOptions): SmsAdapter

type TelnyxOptions = {
  apiKey: string;
  from?: SmsFrom;
  messagingProfileId?: string;
  baseUrl?: string;
  fetch?: FetchLike;
};
```

Also exported from `@opencoredev/sms-sdk/telnyx`: `TELNYX_CAPABILITIES`, `TELNYX_PROVIDER_OPTIONS`, `buildTelnyxRequest(message, messagingProfileId)`, `interpretTelnyxResponse(status, text, headers)`, `telnyxRejectionCategory(status, code)`, `telnyxDelivery(status)`, and the types `TelnyxOptions`, `TelnyxMessageRequest`, `TelnyxSendOptions`.

## Official documentation

Checked on 2026-10-08. The full evidence list is in [PROVIDERS.md](https://github.com/opencoredev/sms-sdk/blob/main/packages/sms-sdk/PROVIDERS.md).

- [Send a message](https://developers.telnyx.com/api/messaging/send-message)
- [Receiving messaging webhooks](https://developers.telnyx.com/docs/messaging/messages/receiving-webhooks)
- [Webhook delivery and signing](https://developers.telnyx.com/development/api-fundamentals/webhooks/receiving-webhooks)
- [API errors](https://developers.telnyx.com/development/api-fundamentals/api-errors)
- [Official Node SDK webhook verification](https://github.com/team-telnyx/telnyx-node/blob/master/src/lib/webhooks.ts)

## Next steps

- [Switch Twilio to Telnyx](/migration/switch-twilio-to-telnyx)
- [Delivery status webhooks](/receiving/delivery-status-webhooks)
- [Retries and fallback](/sending/retries-and-fallback)
