Switch providers
Move SMS sending from one provider to another with SMS SDK: provision the sender, swap the adapter, move webhooks, run both during cutover, and keep a rollback path.
Switching providers is one adapter change in your code. Most of the work happens in the new provider’s account, where you set up a sender, its registration, and webhooks. Do the steps below in order and you can roll back at any point. The examples move from Twilio to Telnyx; the same steps apply to any pair.
Still on the twilio npm package? Do Migrate from the Twilio SDK first.
What changes
Your sms.send() calls, error handling, validate(), hooks, and webhook event handling stay the same. These change:
- The adapter import and its credentials.
- The sender. Numbers, short codes, 10DLC campaigns, and toll-free verifications belong to one provider account. SMS SDK does not move them or your message history.
- Webhook URLs and the credential that verifies them.
- Message IDs. New sends get the new provider’s IDs. Old IDs keep matching late webhooks from the old provider.
Per provider:
| Provider | Adapter credentials | Webhook credential | Gaps to check |
|---|---|---|---|
| Twilio | accountSid plus authToken, or apiKeySid and apiKeySecret |
Auth Token, plus publicUrl behind a proxy |
sendAt needs a Messaging Service sender |
| Telnyx | apiKey; messagingProfileId for alphanumeric senders |
Public key | No validityPeriodSec |
| Plivo | authId, authToken |
Auth Token, plus publicUrl behind a proxy |
No sendAt. Most rejections are request, so fallback rarely triggers. |
| Vonage | applicationId, privateKey (JWT), or apiKey, apiSecret (Basic) |
Signature secret | No MMS, short codes, or sendAt. Basic auth gets no webhooks, so use JWT. |
| Bandwidth | userId, apiToken, apiSecret, applicationId |
Callback username and password | No MMS, sendAt, validityPeriodSec, per-message webhookUrl, or alphanumeric senders |
| Sinch | servicePlanId, apiToken |
Basic credentials or HMAC secret | No MMS. Set providerOptions.sinch.deliveryReport to get status webhooks. |
The provider comparison has the full capability and sender tables.
1. Prepare the new provider
Using Telnyx as the example:
- Create a Telnyx API v2 key.
- Get a sender: buy a number, or port yours. A port moves the number off the old provider, so plan the cutover around it.
- Assign the number to a messaging profile.
- Register it: an A2P 10DLC brand and campaign for US long codes, or toll-free verification. Registrations do not transfer between providers. See Registration and consent.
- Copy the public key from Mission Control, under Keys & Credentials, for webhook verification.
Check the configuration without sending:
TELNYX_API_KEY=... TELNYX_FROM=+15550100002 npx @opencoredev/sms-sdk doctor --adapter telnyx
The doctor CLI works for all six providers. It checks configuration format and reports registration as “not verified”, because only the provider 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",
}),
],
});
A field the new provider does not support throws UnsupportedFieldError instead of being dropped. For Twilio to Telnyx:
- Remove
validityPeriodSec. Telnyx has no such field. sendAtworks from any Telnyx sender, not only a Messaging Service.- A
{ messagingService: "MG…" }sender becomes 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.
3. Move webhooks
Add routes for the new provider next to the old ones. Keep the old routes running until its late status webhooks stop arriving. Both providers parse to SmsEvent, so one function handles both:
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;
}
}
In Telnyx, set the messaging profile’s webhook URL to the new route. It receives both status and inbound events. Where to set the URL for each provider is in Webhook events. Dedupe keys start with the provider name, so keys from the two providers never collide.
4. Carry over opt-outs
The old provider, not the new one, holds the opt-outs from people who replied STOP to your old number. Before the first send on the new provider:
- Make sure your suppression list has every opt-out you received through webhooks.
- Check the list in
beforeSendso it applies to every provider. See STOP, HELP, and opt-outs. - If the provider kept its own list, such as Twilio Advanced Opt-Out, export it and import it into yours.
5. Run both during cutover
Put the new provider first and keep the old one as a safety net:
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",
});
A Telnyx auth, rate_limited, sender, or account rejection now goes through Twilio. Other rejections, and outcomes where Telnyx may have sent the message, never fall back. See Fallback and retries. 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 one message type at a time instead, with a second client for the new provider.
Rollback
Until you release the old number or close the account, rollback is a code change: put the old adapter back as the only or first adapter and redeploy. Keep the old webhook routes and credentials until you are sure.
After the switch
- Remove the old adapter and credentials once no message uses it and its late webhooks have stopped.
- Keep old message IDs in your database. They never match the new provider’s IDs, and they do not need to.
- Compare delivery rates per provider from your stored
providerand status events.