Switch Twilio to Telnyx
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 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
- Create a Telnyx API v2 key.
- 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.
- Assign the number to a messaging profile.
- 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.
- Copy the public key from Mission Control, under Keys & Credentials, for webhook verification.
Check the setup without sending:
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
import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
export const sms = createSmsClient({
adapters: [
twilio({
accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
from: "+15550100001",
}),
telnyx({
apiKey: process.env.TELNYX_API_KEY ?? "",
from: "+15550100002",
}),
],
});
// 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 throwsUnsupportedFieldError, so remove it. sendAtworks 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
messagingProfileIdon thetelnyx()adapter.
Run sms.validate() over a sample of real messages to catch these before you deploy. See the capability matrix 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.
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 dedupeKeys 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
beforeSendso it applies to Telnyx sends. See STOP, HELP, and opt-outs. - 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:
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. 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
providerand status events.