Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 ConfigurationError at startup.
  • To use API keys, pass apiKeySid and apiKeySecret instead of authToken.

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.sid becomes result.providerId. It is the same Twilio SID, so stored IDs and status callbacks still match.
  • message.status values queued, accepted, and scheduled all become result.delivery: "queued".
  • to must be E.164. Any other format throws InvalidRecipientError before the request. See Senders and E.164.
  • An idempotencyKey plus 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.code keeps 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 ProviderRateLimitedError is 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 web Request. Remove express.urlencoded() from webhook routes, or use a framework that passes a web Request. See Use with Hono and Bun.
  • publicUrl replaces the URL argument of validateRequest. See Webhook security.
  • STOP is detected from OptOutType and from keywords. Set detectKeywords: false to 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 contentSid and contentVariables. Keep those sends on the Twilio SDK or write your own adapter. Other Twilio parameters, such as shortenUrls and riskCheck, go in providerOptions.twilio. See Provider options.
  • WhatsApp, Conversations, Verify, and voice. These are out of scope.

Cut over with a rollback path

  1. Add SMS SDK next to the Twilio SDK with the same credentials and numbers.
  2. Move one message type, such as shipping notifications, to sms.send(). Watch onFailure hooks and delivery webhooks.
  3. Move webhook routes to parseSmsWebhook(). Status callbacks carry the same SIDs, so existing rows still match.
  4. Move the remaining message types.
  5. 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.