---
title: "Switch Twilio to Telnyx"
description: "Move SMS sending from Twilio to Telnyx with SMS SDK: provision the sender, swap the adapter, move webhooks, and cut over gradually with a rollback path."
---

This page moves an SMS SDK app from Twilio to Telnyx. The code change is one adapter. Most of the work is in your Telnyx account, where you set up a sender, its registration, and webhooks. The steps run in an order that keeps rollback possible.

Still on the `twilio` npm package? Do [Migrate from the Twilio SDK](/migration/migrate-from-twilio) first.

## What changes and what does not

| | Changes | Stays the same |
| --- | --- | --- |
| Code | The adapter import and its options | Every `sms.send()` call, error handling, `validate()`, hooks |
| Credentials | Telnyx API key and public key replace the Twilio Auth Token | Where you store secrets |
| Sender | A number, profile, or sender ID provisioned and registered on Telnyx | Your recipients |
| Webhooks | New URLs or routes configured in Telnyx, verified with the Telnyx public key | Your event handling, because events have the same `SmsEvent` shape |
| Stored message IDs | New sends get Telnyx IDs | Old Twilio IDs keep matching late Twilio webhooks |

SMS SDK does not move numbers, 10DLC campaigns, toll-free verifications, or message history. You handle those with the providers.

## 1. Prepare Telnyx

1. Create a Telnyx API v2 key.
2. Get a sender on Telnyx: buy a number, or port your Twilio number. A port moves the number off Twilio, so plan the cutover around it.
3. Assign the number to a messaging profile.
4. Register it with an A2P 10DLC brand and campaign for US long codes, or toll-free verification. Twilio registrations do not transfer. See [Sender registration and consent](/guides/sender-registration-and-consent).
5. Copy the public key from Mission Control, under Keys & Credentials, for webhook verification.

Check the setup without sending:

```bash
TELNYX_API_KEY=... TELNYX_FROM=+15550100002 npx @opencoredev/sms-sdk doctor --adapter telnyx
```

The doctor checks the configuration format. It reports registration as "not verified" because only Telnyx can confirm it.

## 2. Swap the adapter

```ts sms.ts
import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio"; // [!code --]
import { telnyx } from "@opencoredev/sms-sdk/telnyx"; // [!code ++]

export const sms = createSmsClient({
  adapters: [
    twilio({ // [!code --]
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "", // [!code --]
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "", // [!code --]
      from: "+15550100001", // [!code --]
    }), // [!code --]
    telnyx({ // [!code ++]
      apiKey: process.env.TELNYX_API_KEY ?? "", // [!code ++]
      from: "+15550100002", // [!code ++]
    }), // [!code ++]
  ],
});

// Unchanged
await sms.send({
  to: "+14155550123",
  body: "Your order has shipped.",
  idempotencyKey: "order:123:shipped:v1",
});
```

Your `sms.send()` calls stay as they are. A few fields behave differently on Telnyx:

- Telnyx does not support `validityPeriodSec`. A send that sets it throws `UnsupportedFieldError`, so remove it.
- `sendAt` works from any Telnyx sender, not only a Messaging Service.
- A `{ messagingService: "MG…" }` sender must become a Telnyx messaging profile ID or a number.
- Alphanumeric senders need `messagingProfileId` on the `telnyx()` adapter.

Run `sms.validate()` over a sample of real messages to catch these before you deploy. See the [capability matrix](/providers/overview) for other differences.

## 3. Move webhooks

Add Telnyx routes next to the Twilio ones. Keep the Twilio routes running until late Twilio status callbacks stop arriving.

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

async function handle(event: SmsEvent): Promise<void> {
  if (!(await db.webhookEvents.insertIfNew(event.dedupeKey))) return;
  if (event.type === "message.delivered" || event.type === "message.undelivered" || event.type === "message.filtered") {
    await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
  } else if (event.type === "recipient.opted_out") {
    await db.suppressions.add(event.from);
  }
}

export async function telnyxWebhook(request: Request): Promise<Response> {
  try {
    await handle(await parseSmsWebhook({ provider: "telnyx", request, credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" } }));
    return new Response(null, { status: 200 });
  } catch (error) {
    if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
    throw error;
  }
}

export async function twilioWebhook(request: Request): Promise<Response> {
  try {
    await handle(
      await parseSmsWebhook({
        provider: "twilio",
        request,
        publicUrl: "https://example.com/webhooks/sms/twilio",
        credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
      }),
    );
    return new Response("<Response></Response>", { headers: { "content-type": "text/xml" } });
  } catch (error) {
    if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
    throw error;
  }
}
```

One `handle()` serves both providers because both parse to `SmsEvent`. In Telnyx, set the messaging profile's webhook URL to the Telnyx route. It receives both status and inbound events. You can also pass `webhookUrl` per message.

Telnyx `dedupeKey`s start with `telnyx:` and use the event ID, so they never collide with Twilio keys.

## 4. Carry over opt-outs

Twilio, not Telnyx, holds the opt-outs from people who replied STOP to your Twilio number. Before the first Telnyx send:

- Make sure your suppression list has every opt-out you received from Twilio webhooks.
- Check the list in `beforeSend` so it applies to Telnyx sends. See [STOP, HELP, and opt-outs](/receiving/stop-help-and-opt-outs#block-sends-to-suppressed-recipients).
- If you used Twilio Advanced Opt-Out, export its opt-out list and import it into your suppression list.

## 5. Cut over gradually

Run both providers for a while, with Telnyx first and Twilio as a safety net for account-level problems:

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

export const sms = createSmsClient({
  adapters: [
    telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100002" }),
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from: "+15550100001",
    }),
  ],
  fallback: "on-known-rejection",
});
```

With this config, a Telnyx `auth`, `rate_limited`, `sender`, or `account` rejection goes through Twilio instead. Other rejections and unknown outcomes never fall back. See [Retries and fallback](/sending/retries-and-fallback). Watch `result.attemptedProviders` or the `onAttempt` hook to see how often Twilio is used.

Each adapter sends from its own number, so recipients may see two sender numbers during this phase. If that matters, move message types one at a time instead. Create a second client for Telnyx and switch call sites gradually.

## Rollback

Until you release the Twilio number or close the account, rollback is a code change. Put `twilio(...)` back as the only or first adapter and redeploy. Keep the Twilio webhook routes and credentials until you are sure.

## After the switch

- Remove the Twilio adapter and credentials when no message uses Twilio and late Twilio webhooks have stopped.
- Keep old Twilio message IDs in your database. They will never match Telnyx IDs, and they don't need to.
- Compare delivery rates per provider from your stored `provider` and status events.

## Next steps

- [Telnyx provider page](/providers/telnyx)
- [Retries and fallback](/sending/retries-and-fallback)
