Migrate from the Twilio SDK
Move SMS sending and webhooks from the twilio npm package to SMS SDK while staying on Twilio: concept mapping, code, and a rollback path.
This page moves SMS code from the twilio npm package to SMS SDK while you stay on Twilio. The account, numbers, and webhook URLs do not change. To change providers afterwards, see Switch Twilio to Telnyx.
Both libraries can run side by side during the move. SMS SDK replaces only the SMS parts, so keep the Twilio SDK for voice, Verify, Lookup, and the rest.
Inventory what must keep working
List what your current integration does and find its replacement:
| Behavior | Twilio SDK | SMS SDK |
|---|---|---|
| Send a text | client.messages.create({ to, from, body }) |
sms.send({ to, body }) |
| Send from a Messaging Service | messagingServiceSid |
from: { messagingService: "MG…" } |
| MMS | mediaUrl |
mediaUrls |
| Status callback per message | statusCallback |
webhookUrl |
| Scheduling | sendAt + scheduleType: "fixed" |
sendAt (Messaging Service sender) |
| Validity period | validityPeriod |
validityPeriodSec |
| Verify webhook signature | twilio.validateRequest(...) |
parseSmsWebhook({ provider: "twilio", ... }) |
| Read status callback | req.body.MessageStatus |
event.type, event.providerStatus |
| Read inbound message | req.body.Body, req.body.From |
event.body, event.from |
| Handle errors | RestException with status, code |
Typed errors with code, category, retrySafe |
| Reply with TwiML | MessagingResponse |
Not provided. Send a new message, or keep TwiML. |
| Fetch or list messages | client.messages(sid).fetch() |
Not provided. Keep the Twilio SDK for this. |
1. Create the client
Before
import Twilio from "twilio";
export const client = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
After
import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
export const sms = createSmsClient({
adapters: [
twilio({
accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
from: { messagingService: process.env.TWILIO_MESSAGING_SERVICE_SID ?? "" },
}),
],
});
Key changes
- The default sender moves into the adapter, so call sites no longer pass
from. - A malformed Account SID or empty token throws
ConfigurationErrorat startup. - To use API keys, pass
apiKeySidandapiKeySecretinstead ofauthToken.
2. Send a message
Before
const message = await client.messages.create({
to: "+14155550123",
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
body: "Your order has shipped.",
statusCallback: "https://example.com/webhooks/sms/twilio",
});
await db.messages.create({ sid: message.sid, status: message.status });
After
import { db } from "./db";
import { sms } from "./sms";
const result = await sms.send({
to: "+14155550123",
body: "Your order has shipped.",
webhookUrl: "https://example.com/webhooks/sms/twilio",
idempotencyKey: "order:123:shipped:v1",
});
await db.messages.create({ id: result.id, provider: result.provider, providerId: result.providerId, to: "+14155550123", status: result.delivery });
Key changes
message.sidbecomesresult.providerId. It is the same Twilio SID, so stored IDs and status callbacks still match.message.statusvaluesqueued,accepted, andscheduledall becomeresult.delivery: "queued".tomust be E.164. Any other format throwsInvalidRecipientErrorbefore the request. See Senders and E.164.- An
idempotencyKeyplus a store stops retried jobs from sending twice. See Idempotency.
3. Handle errors
Before
try {
await client.messages.create({ to, from, body });
} catch (error) {
if (error.code === 21610) {
await db.suppressions.add(to); // unsubscribed
} else if (error.status >= 500) {
// retry? the message may or may not exist
}
}
After
import { HandoffUnknownError, ProviderRejectedError } from "@opencoredev/sms-sdk";
import { db } from "./db";
import { sms } from "./sms";
const to = "+14155550123";
try {
await sms.send({ to, body: "Your order has shipped.", idempotencyKey: "order:123:shipped:v1" });
} catch (error) {
if (error instanceof ProviderRejectedError && error.category === "compliance") {
await db.suppressions.add(to); // Twilio 21610
} else if (error instanceof HandoffUnknownError) {
await db.messages.markNeedsReview("order:123:shipped:v1", error.reason); // 5xx or timeout: do not resend blindly
} else {
throw error;
}
}
Key changes
- Twilio error codes map to categories, and
error.provider.codekeeps the raw code, such as"21610". See the Twilio error mapping. - 5xx responses, timeouts, and network errors throw
HandoffUnknownError, which SMS SDK never retries. See Idempotency for how to resolve them. - A 429 with Twilio error 20429 is retried once by default before
ProviderRateLimitedErroris thrown. A 429 without that code is an unknown outcome.
4. Verify and parse webhooks
Before
app.post("/webhooks/sms/twilio", express.urlencoded({ extended: false }), (req, res) => {
const valid = twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN, req.header("X-Twilio-Signature"), "https://example.com/webhooks/sms/twilio", req.body);
if (!valid) return res.status(403).end();
if (req.body.MessageStatus === "delivered") markDelivered(req.body.MessageSid);
if (req.body.OptOutType === "STOP") suppress(req.body.From);
res.type("text/xml").send("<Response></Response>");
});
After
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: "twilio",
request,
publicUrl: "https://example.com/webhooks/sms/twilio",
credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
});
if (event.type === "message.delivered") await db.messages.updateStatus(event.providerId, event.type);
if (event.type === "recipient.opted_out") await db.suppressions.add(event.from);
return new Response("<Response></Response>", { headers: { "content-type": "text/xml" } });
} catch (error) {
if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 403 });
throw error;
}
}
Key changes
parseSmsWebhook()needs the raw webRequest. Removeexpress.urlencoded()from webhook routes, or use a framework that passes a webRequest. See Use with Hono and Bun.publicUrlreplaces the URL argument ofvalidateRequest. See Webhook security.- STOP is detected from
OptOutTypeand from keywords. SetdetectKeywords: falseto keep only Twilio’s signal.
What has no equivalent
- TwiML replies. Return TwiML yourself, or reply with
sms.send(). - Fetching, listing, updating, or deleting messages. Keep the Twilio SDK for these.
- Content templates through
contentSidandcontentVariables. Keep those sends on the Twilio SDK or write your own adapter. Other Twilio parameters, such asshortenUrlsandriskCheck, go inproviderOptions.twilio. See Provider options. - WhatsApp, Conversations, Verify, and voice. These are out of scope.
Cut over with a rollback path
- Add SMS SDK next to the Twilio SDK with the same credentials and numbers.
- Move one message type, such as shipping notifications, to
sms.send(). WatchonFailurehooks and delivery webhooks. - Move webhook routes to
parseSmsWebhook(). Status callbacks carry the same SIDs, so existing rows still match. - Move the remaining message types.
- To roll back a message type, point its call site back at
client.messages.create(). Nothing changed on the Twilio side, so there is nothing to undo there.
Need help?
Open a GitHub discussion or issue with your current Twilio code and what you expected.