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-sdkpnpm add @opencoredev/sms-sdkyarn add @opencoredev/sms-sdkbun add @opencoredev/sms-sdknub add @opencoredev/sms-sdkaube add @opencoredev/sms-sdkimport { 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,FromorMessagingServiceSid,Body,MediaUrl(repeated),StatusCallback(fromwebhookUrl),ValidityPeriod(fromvalidityPeriodSec),SendAtwithScheduleType=fixed(fromsendAt), and any provider options. - Success: a 2xx response with a
sidmatchingSMorMMplus 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:
- US long codes need A2P 10DLC registration before they send to the US.
- Toll-free numbers can’t send to the US or Canada until toll-free verification is approved.
- US short codes are supported, with a provisioning time of 6 to 10 weeks.
- Alphanumeric sender IDs are not supported in the US.
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
webhookUrlper 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
publicUrlwith the exact URL configured in Twilio, or verification fails behind a proxy. SMS SDK tries the URL with and without:443or: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 replacesbody. 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.
- Message resource
- Error response format
- Error dictionary
- Webhook request validation
- Incoming message webhook parameters
- Tracking outbound message status
- Messaging Services