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

Twilio

Send and receive SMS and MMS through Twilio Programmable Messaging with the SMS SDK twilio() adapter: setup, senders, webhooks, errors, and limits.

The twilio() adapter sends SMS and MMS through Twilio Programmable Messaging and parses Twilio’s status and inbound webhooks.

To compare providers, see the Providers overview.

Support status

Supported. Request fields, responses, error codes, status values, and webhook signing follow Twilio’s official documentation. Twilio’s published signature example is a test vector, and the adapter passes the shared contract tests.

Installation

npm install @opencoredev/sms-sdk
pnpm add @opencoredev/sms-sdk
yarn add @opencoredev/sms-sdk
bun add @opencoredev/sms-sdk
nub add @opencoredev/sms-sdk
aube add @opencoredev/sms-sdk
import { twilio } from "@opencoredev/sms-sdk/twilio";

Environment variables

Variable Required Used for
TWILIO_ACCOUNT_SID Yes Account SID, AC plus 32 hex characters. Part of the request URL.
TWILIO_AUTH_TOKEN Yes, unless you use an API key Basic auth password, and webhook signature verification.
TWILIO_API_KEY_SID, TWILIO_API_KEY_SECRET Instead of the Auth Token Basic auth with an API key.
TWILIO_FROM One sender variable Default sender: a Twilio number, short code, or alphanumeric sender ID.
TWILIO_MESSAGING_SERVICE_SID One sender variable Default sender: a Messaging Service, MG plus 32 hex characters.

The examples and the doctor CLI use these names. The adapter itself reads no environment variables. Keep the token on the server.

Basic usage

import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";

const sms = createSmsClient({
  adapters: [
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from: "+15550100001",
    }),
  ],
});

const result = await sms.send({ to: "+14155550123", body: "Your order has shipped." });
console.log(result.providerId); // "SM..." (or "MM..." for MMS)

twilio() throws ConfigurationError at startup when the Account SID is malformed or a credential is empty.

Authentication

Pass either the Auth Token or an API key, not both:

import { twilio } from "@opencoredev/sms-sdk/twilio";

const withApiKey = twilio({
  accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
  apiKeySid: process.env.TWILIO_API_KEY_SID ?? "",
  apiKeySecret: process.env.TWILIO_API_KEY_SECRET ?? "",
  from: { messagingService: process.env.TWILIO_MESSAGING_SERVICE_SID ?? "" },
});

console.log(withApiKey.name); // "twilio"

Twilio recommends API keys for production because you can revoke each one on its own. Webhook verification still needs the account’s Auth Token, because Twilio signs webhooks with it and not with an API key.

Configuration

Option Type Default Meaning
accountSid string required AC plus 32 hex characters.
authToken string required without API key Auth Token.
apiKeySid, apiKeySecret string required without Auth Token API key pair.
from SmsFrom none Default sender.
baseUrl string https://api.twilio.com API origin, for a proxy or a mock server.
fetch FetchLike global fetch Custom fetch, for tests or runtimes without a global one.

API version and endpoint

  • POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json
  • HTTP Basic auth, form-encoded body.
  • Fields sent: To, From or MessagingServiceSid, Body, MediaUrl (repeated), StatusCallback (from webhookUrl), ValidityPeriod (from validityPeriodSec), SendAt with ScheduleType=fixed (from sendAt), and any provider options.
  • Success: a 2xx response with a sid matching SM or MM plus 32 hex characters. A 2xx without one is an unknown outcome.

Senders

Sender Write Sent as
Long code or toll-free number "+15550100001" From
Short code { shortCode: "12345" } From
Alphanumeric sender ID { senderId: "Acme" } From
Messaging Service { messagingService: "MG…" } MessagingServiceSid

Which senders work depends on the destination country. Twilio decides, and a refused sender is a sender rejection.

Regions and countries

Twilio documents these rules for the United States and Canada:

Elsewhere, alphanumeric sender IDs work only in supported countries, and some countries require you to register the sender ID. Twilio’s SMS guidelines list sender types and registration rules for each country.

SMS SDK does not check these rules before sending, and they have not been tested against live accounts. See Sender registration and consent.

Capabilities

Capability Twilio adapter
MMS Yes, up to 10 mediaUrls
Scheduling Yes, only with a { messagingService } sender
Validity period 1 to 36000 seconds
Per-message webhookUrl Yes
Delivery status webhooks Yes
Inbound webhooks Yes
Body length Up to 1600 characters (checked locally)
Native idempotency No

Provider options

Pass Twilio’s other Message parameters in providerOptions.twilio:

import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";

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 ?? "" },
    }),
  ],
});

await sms.send({
  to: "+14155550123",
  body: "Track your order: https://example.com/orders/123",
  providerOptions: {
    twilio: { shortenUrls: true, smartEncoded: true, messageIntent: "delivery" },
  },
});
Option Twilio parameter Type
applicationSid ApplicationSid string, AP plus 32 hex characters
provideFeedback ProvideFeedback boolean
attempt Attempt integer, 1 or more
contentRetention ContentRetention "retain" | "discard"
addressRetention AddressRetention "retain" | "obfuscate"
smartEncoded SmartEncoded boolean
shortenUrls ShortenUrls boolean. Needs a { messagingService } sender, or the send throws UnsupportedFieldError.
sendAsMms SendAsMms boolean
messageIntent MessageIntent "otp" | "notifications" | "marketing" | "fraud" | "security" | "customercare" | "delivery" | "education" | "polling" | "announcements" | "events"
riskCheck RiskCheck "enable" | "disable"
extra any other parameter Record<string, string | number | boolean>, sent as is

Unknown keys, wrong types, and extra keys that name a field SMS SDK sets throw before any request. The options go out only when the twilio adapter sends. See providerOptions for fallback and idempotency.

SMS SDK sets these parameters itself, so extra cannot: To, From, MessagingServiceSid, Body, MediaUrl, StatusCallback, ValidityPeriod, SendAt, and ScheduleType. ContentSid and ContentVariables are blocked too, because a Content Template replaces Body, which SMS SDK uses for segment estimates, policy checks, and idempotency. MaxPrice (obsolete), ForceDelivery (reserved by Twilio), and TrafficType (undocumented) have no typed option; send them through extra if you need them.

Example: send and track delivery

This script sends one message with a status callback. It is based on examples/twilio, which runs against a mocked Twilio API unless you opt into a live send.

import { createSmsClient, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";

const from = process.env.TWILIO_FROM;
const to = process.env.SMS_EXAMPLE_TO;
if (!isE164(from) || !isE164(to)) {
  throw new Error("Set TWILIO_FROM and SMS_EXAMPLE_TO to E.164 numbers.");
}

const sms = createSmsClient({
  adapters: [
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from,
    }),
  ],
});

try {
  const result = await sms.send({
    to,
    body: "Your order has shipped.",
    idempotencyKey: "order:123:shipped:v1",
    webhookUrl: "https://example.com/webhooks/sms/twilio",
  });
  console.log(`Twilio accepted ${result.providerId} (${result.delivery})`);
} catch (error) {
  if (isSmsError(error)) console.error(error.toJSON());
  throw error;
}

You see Twilio accepted SM… (queued). Acceptance is not delivery. Twilio posts status updates to the webhookUrl, covered in Delivery status webhooks.

Delivery and inbound webhooks

  • Status: pass webhookUrl per message, or set a status callback on the Messaging Service.
  • Inbound: set “A message comes in” on the phone number, or the incoming message webhook on the Messaging Service, to your endpoint with HTTP POST.
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" || event.type === "message.undelivered") {
      await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
    } else if (event.type === "recipient.opted_out") {
      await db.suppressions.add(event.from);
    }
    // Empty TwiML: no automatic reply.
    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;
  }
}

Status mapping: queued, accepted, scheduled, and sending become message.queued; sent becomes message.sent; delivered becomes message.delivered; undelivered and failed become message.undelivered, or message.filtered with error 30007. read, canceled, and other statuses become unrecognized. dedupeKey is twilio:{MessageSid}:{status} or twilio:{MessageSid}:received.

With Advanced Opt-Out enabled on a Messaging Service, Twilio adds OptOutType (STOP, START, HELP) to inbound webhooks, which becomes recipient.opted_out, recipient.opted_in, or recipient.help with source: "provider".

Signature verification

Twilio signs each webhook with X-Twilio-Signature. The signature is a Base64 HMAC-SHA1, keyed with the Auth Token, over the full URL followed by each form parameter name and value, sorted by name. For JSON bodies, Twilio adds a bodySHA256 query parameter and signs only the URL. SMS SDK then checks that hash against the raw body.

  • Pass publicUrl with the exact URL configured in Twilio, or verification fails behind a proxy. SMS SDK tries the URL with and without :443 or :80. See Webhook security.
  • Twilio signs no timestamp, so there is no replay window. Deduplicate on dedupeKey.
  • The Auth Token must belong to the account that owns the number. A subaccount number is signed with the subaccount’s token.

Error mapping

Twilio response Category Falls back
401, or error 20003 auth Yes
403 with an unlisted code auth Yes
Error 20429 rate_limited Yes, after retries
21212, 21606, 21612, 21659, 21660, 21703 sender Yes
21408 (geographic permission), 21608 (trial account) account Yes
21211, 21614 recipient No
21610 (recipient unsubscribed) compliance Never
Any other 4xx request No
429 without error 20429 unknown Never
5xx, network error, timeout, 2xx without a valid SID unknown Never

Twilio documents that a 429 with error 20429 was not processed. A 429 without that code may come from a proxy or load balancer, so SMS SDK treats it as unknown. error.provider.code holds Twilio’s error code, and error.provider.requestId holds the Twilio-Request-Id header.

Limitations

  • Scheduling needs a Messaging Service sender. To schedule from a plain number, use Telnyx.
  • Content Templates (ContentSid) are not supported, because a template replaces body. WhatsApp and Conversations are out of scope.
  • Twilio enforces its own scheduling window and MMS country coverage, and rejects sends outside them.

Testing

Unit test your code with the memory() adapter, or point twilio() at a fake API with mockFetch:

import { createSmsClient } from "@opencoredev/sms-sdk";
import { mockFetch } from "@opencoredev/sms-sdk/testing";
import { twilio } from "@opencoredev/sms-sdk/twilio";

const fake = mockFetch({ status: 201, body: { sid: "SM0123456789abcdef0123456789abcdef", status: "queued" } });
const sms = createSmsClient({
  adapters: [twilio({ accountSid: "AC0123456789abcdef0123456789abcdef", authToken: "test", from: "+15550100001", fetch: fake.fetch })],
});

await sms.send({ to: "+14155550123", body: "Hi" });
console.log(fake.calls[0]?.body); // "To=%2B14155550123&From=%2B15550100001&Body=Hi"

The adapter’s own tests live in the SMS SDK repository. From packages/sms-sdk:

bun test test/contracts/twilio.test.ts test/webhooks/twilio.test.ts

The live test sends one real, billable message to each configured provider. It is skipped unless you opt in:

LIVE_SMS_TESTS=true LIVE_SMS_TO=+14155550123 LIVE_SMS_ALLOWLIST=+14155550123 \
TWILIO_ACCOUNT_SID=AC... TWILIO_AUTH_TOKEN=... TWILIO_FROM=+15550100001 \
bun test test/integration/live.test.ts

LIVE_SMS_TO must appear in LIVE_SMS_ALLOWLIST, and providers without credentials in the environment are skipped. Check your configuration first with npx @opencoredev/sms-sdk doctor --adapter twilio.

API reference

twilio(options: TwilioOptions): SmsAdapter

type TwilioOptions =
  | { accountSid: string; authToken: string; from?: SmsFrom; baseUrl?: string; fetch?: FetchLike }
  | { accountSid: string; apiKeySid: string; apiKeySecret: string; from?: SmsFrom; baseUrl?: string; fetch?: FetchLike };

Also exported from @opencoredev/sms-sdk/twilio, for adapter authors and tests: TWILIO_CAPABILITIES, TWILIO_PROVIDER_OPTIONS, buildTwilioForm(message), interpretTwilioResponse(status, text, headers), twilioRejectionCategory(status, code), twilioDelivery(status), and the types TwilioOptions, TwilioAuthTokenOptions, TwilioApiKeyOptions, TwilioSendOptions.

Official documentation

Checked on 2026-10-08. The full evidence list is in PROVIDERS.md.

Next steps