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 verifies Twilio’s status and inbound webhooks. To compare providers, see Choose a provider.
Support status
Supported. Requests, responses, error codes, statuses, and webhook signing follow Twilio’s official documentation, and the adapter passes the shared contract tests. Limits to know:
- Scheduling (
sendAt) works only from a Messaging Service sender. - Content Templates (
ContentSid) are not supported. - Webhook verification needs the Auth Token of the account that owns the number. An API key cannot verify webhooks.
- Twilio signs no timestamp, so webhooks have no replay window. Deduplicate on
dedupeKey.
Setup
npm install @opencoredev/sms-sdkpnpm add @opencoredev/sms-sdkyarn add @opencoredev/sms-sdkbun add @opencoredev/sms-sdknub add @opencoredev/sms-sdkaube add @opencoredev/sms-sdk| Variable | Required | Used for |
|---|---|---|
TWILIO_ACCOUNT_SID |
Yes | Account SID, AC plus 32 hex characters. |
TWILIO_AUTH_TOKEN |
Yes, unless you use an API key | API auth, and webhook verification. |
TWILIO_API_KEY_SID, TWILIO_API_KEY_SECRET |
Instead of the Auth Token | API auth with a revocable key. Twilio recommends this for production. |
TWILIO_FROM or TWILIO_MESSAGING_SERVICE_SID |
One of them | Default sender. The service SID is MG plus 32 hex characters. |
The adapter reads no environment variables itself; the examples and the doctor CLI use these names. Keep the secrets on the server.
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 ?? "",
// Or, instead of authToken: apiKeySid and apiKeySecret.
from: "+15550100001",
}),
],
});
const result = await sms.send({ to: "+14155550123", body: "Your order has shipped." });
console.log(result.providerId); // "SM..." ("MM..." for MMS)
Pass either authToken or apiKeySid with apiKeySecret, not both. twilio() throws ConfigurationError at startup when the Account SID is malformed or a credential is empty. Two more options exist for tests and proxies: baseUrl (default https://api.twilio.com) and fetch. To test without a Twilio account, see Test without a provider.
The adapter calls POST /2010-04-01/Accounts/{AccountSid}/Messages.json with a form body. A 2xx without a valid SM or MM SID 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, and a sender Twilio refuses is a sender rejection. In the US, long codes need A2P 10DLC registration, toll-free numbers need verification before they reach the US or Canada, and alphanumeric sender IDs are not supported. Twilio’s SMS guidelines list the rules for each country. SMS SDK does not check them before sending; see Registration and consent.
Features
| Feature | Twilio |
|---|---|
| MMS | Up to 10 mediaUrls |
Scheduling (sendAt) |
Only with a { messagingService } sender, inside Twilio’s scheduling window |
| Validity period | 1 to 36000 seconds |
Per-message webhookUrl |
Yes, sent as StatusCallback |
| Body length | Up to 1600 characters, checked before sending |
Provider options
Pass other Twilio Message parameters in providerOptions.twilio:
import { sms } from "./sms";
await sms.send({
to: "+14155550123",
body: "Track your order: https://example.com/orders/123",
providerOptions: { twilio: { 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 and wrong types throw before any request, and the options go out only when the twilio adapter sends. extra cannot set the fields SMS SDK owns: To, From, MessagingServiceSid, Body, MediaUrl, StatusCallback, ValidityPeriod, SendAt, ScheduleType, ContentSid, and ContentVariables. MaxPrice, ForceDelivery, and TrafficType have no typed option; send them through extra. See providerOptions for how options interact with fallback and idempotency.
Webhooks
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;
}
}
- Credential: the Auth Token of the account that owns the number. A subaccount’s number is signed with the subaccount’s token. API keys cannot verify.
- Point Twilio at it: for status, pass
webhookUrlper message or set a status callback on the Messaging Service. For inbound, set “A message comes in” on the number, or the incoming message webhook on the Messaging Service, with HTTP POST. publicUrl: pass the exact URL configured in Twilio, or verification fails behind a proxy. SMS SDK also tries it with and without:443or:80. See Webhook security.- Signing:
X-Twilio-Signatureis an HMAC-SHA1 over the URL and the sorted form parameters. For JSON bodies, Twilio signs only the URL and adds abodySHA256query parameter, which SMS SDK checks against the raw body. - Replay window: none, because Twilio signs no timestamp. Deduplicate on
dedupeKey.
| Twilio status | Event |
|---|---|
queued, accepted, scheduled, sending |
message.queued |
sent |
message.sent |
delivered |
message.delivered |
undelivered, failed |
message.undelivered, or message.filtered with error 30007 |
read, canceled, others |
unrecognized |
| Inbound message | message.received |
Inbound with OptOutType STOP, START, HELP |
recipient.opted_out, recipient.opted_in, recipient.help (source: "provider") |
OptOutType appears when Advanced Opt-Out is on for the Messaging Service. dedupeKey is twilio:{MessageSid}:{status} for status updates and twilio:{MessageSid}:received for inbound messages.
Limitations
- Scheduling needs a Messaging Service sender. To schedule from a plain number, use Telnyx.
- Content Templates are not supported, because a template replaces
body, which SMS SDK needs for segment estimates, policy checks, and idempotency. WhatsApp and Conversations are out of scope. - Twilio enforces its own scheduling window and MMS country coverage, and rejects sends outside them.
Error codes
| 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, so SMS SDK treats it as unknown. error.provider.code holds Twilio’s error code and error.provider.requestId the Twilio-Request-Id header. For adapter authors, the subpath also exports buildTwilioForm, interpretTwilioResponse, twilioRejectionCategory, and twilioDelivery.
Official documentation
Checked on 2026-10-08. The full evidence list is in PROVIDERS.md.
- Message resource
- Error dictionary
- Webhook request validation
- Incoming message webhook parameters
- Tracking outbound message status
- Messaging Services