---
title: "Migrate from the Twilio SDK"
description: "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](/migration/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

```ts ignore="Twilio SDK code; the twilio package is not a docs dependency"
import Twilio from "twilio";

export const client = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
```

### After

```ts sms.ts
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

```ts ignore="Twilio SDK code"
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

```ts
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](/sending/senders-and-e164).
- An `idempotencyKey` plus a store stops retried jobs from sending twice. See [Idempotency](/sending/idempotency).

## 3. Handle errors

### Before

```ts ignore="Twilio SDK code"
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

```ts
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](/providers/twilio#error-mapping).
- 5xx responses, timeouts, and network errors throw `HandoffUnknownError`, which SMS SDK never retries. See [Idempotency](/sending/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

```ts ignore="Express and Twilio SDK code"
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

```ts
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](/guides/hono-and-bun).
- `publicUrl` replaces the URL argument of `validateRequest`. See [Webhook security](/receiving/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](/providers/writing-an-adapter). Other Twilio parameters, such as `shortenUrls` and `riskCheck`, go in `providerOptions.twilio`. See [Provider options](/providers/twilio#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](/community/support) with your current Twilio code and what you expected.
