Guide · MCP Twilio Integration
MCP Server Twilio — SMS with E.164 validation, call listing, number lookup, and /health via account fetch
Twilio is the de facto standard for programmatic SMS and voice communications in agent workflows — from two-factor delivery to on-call alerting via text. This guide covers building TypeScript MCP tools for the Twilio REST API: HTTP Basic Auth with Account SID and Auth Token, sending SMS with E.164 number validation and confirm guards, listing messages and calls with date range filters, checking number capabilities via the Lookup API, and wiring a /health/twilio endpoint that fetches your account to verify credentials before AliveMCP catches silent credential failures.
TL;DR
Twilio REST API uses HTTP Basic Auth — Account SID as username, Auth Token as password. The base URL is https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/. Successful message creation returns HTTP 201, not 200. Always validate phone numbers in E.164 format (+15551234567) before sending — Twilio error code 21211 means invalid To number. SMS cannot be recalled once sent — add a confirm: z.literal(true) guard. Health: GET /Accounts/{AccountSid} fetches account details and fails with 401 on invalid credentials or 403 on suspended accounts.
SDK setup and authentication
The official twilio npm package is a comprehensive REST client with full TypeScript types. It handles the HTTP Basic Auth header, response parsing, and pagination. For lightweight MCP integrations, you can also use raw axios with form-urlencoded bodies, since the Twilio REST API predates JSON request bodies for core resources.
import Twilio from 'twilio';
// Account SID looks like: AC32a3c49700934481addd5ce1659f04d5
// Auth Token is 32 hex characters
const client = Twilio(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!
);
// The Twilio helper library sends requests to:
// https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/...
// with Authorization: Basic base64(AccountSid:AuthToken)
// If using raw axios instead of the SDK:
import axios from 'axios';
const twilioHttp = axios.create({
baseURL: `https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_ACCOUNT_SID}`,
auth: {
username: process.env.TWILIO_ACCOUNT_SID!,
password: process.env.TWILIO_AUTH_TOKEN!
},
// Critical: Twilio request bodies are application/x-www-form-urlencoded,
// NOT application/json for the 2010-04-01 REST API
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
| Credential | Format | Where to find |
|---|---|---|
| Account SID | AC + 32 hex chars |
Twilio Console dashboard → Account Info |
| Auth Token | 32 hex chars | Twilio Console → Account Info (toggle to reveal) |
| API Key SID | SK + 32 hex chars |
Twilio Console → API keys (preferred for production) |
| API Key Secret | 32 chars | Shown once on creation — must be stored immediately |
For production MCP servers, prefer API Key + Secret over the primary Auth Token. If an API key is compromised, you can revoke just that key without rotating the account's primary credentials.
Sending SMS with E.164 validation and confirm guards
SMS cannot be recalled — once Twilio accepts the message, it's queued for delivery. The send_sms tool must validate phone number format before sending and require an explicit confirm flag. E.164 format is + followed by country code and local number, no spaces, dashes, or parentheses.
// E.164 validator: + followed by 7-15 digits
const E164_REGEX = /^\+[1-9]\d{6,14}$/;
server.tool('send_sms', {
to: z.string()
.regex(E164_REGEX, 'Phone number must be in E.164 format: +15551234567')
.describe('Recipient phone number in E.164 format.'),
from: z.string()
.regex(E164_REGEX)
.describe('Your Twilio phone number in E.164 format.'),
body: z.string().max(1600).describe('SMS message body. Over 160 chars = multipart SMS.'),
confirm: z.literal(true).describe('SMS cannot be recalled once sent. Pass true to confirm.')
}, async ({ to, from, body }) => {
// Twilio SDK create() returns the message resource on success
const message = await client.messages.create({ to, from, body });
// Success returns HTTP 201. The message starts in 'queued' status.
// Delivery status updates arrive via webhook (StatusCallback URL).
return {
content: [{
type: 'text',
text: JSON.stringify({
sid: message.sid,
status: message.status, // 'queued', 'sending', 'sent', 'delivered', 'failed'
to: message.to,
from: message.from,
date_created: message.dateCreated
}, null, 2)
}]
};
});
| Twilio error code | Meaning | Fix |
|---|---|---|
| 21211 | Invalid To phone number |
Validate E.164 format before calling the API |
| 21612 | Cannot route to this number | Number is unreachable via Twilio's network |
| 21614 | To number not SMS-capable | Use Lookup API to check sms capability first |
| 21408 | Permission to send to this region disabled | Enable geographic permissions in Twilio Console |
| 30003 | Unreachable destination handset | Delivery failure — device off or out of range |
Listing messages and calls with date filters
The message and call listing endpoints support filtering by date range via DateSent>= and DateSent<= parameters (note the comparison operator suffix). Results are paginated via Twilio's standard cursor pagination — the response includes a next_page_uri field when more records exist.
server.tool('list_twilio_messages', {
date_from: z.string().optional().describe('ISO date string (YYYY-MM-DD). Filter messages sent on or after.'),
date_to: z.string().optional().describe('ISO date string (YYYY-MM-DD). Filter messages sent on or before.'),
to: z.string().optional().describe('Filter by recipient E.164 number.'),
status: z.enum(['queued', 'sending', 'sent', 'failed', 'delivered', 'undelivered', 'receiving', 'received']).optional(),
page_size: z.number().int().min(1).max(1000).default(50)
}, async ({ date_from, date_to, to, status, page_size }) => {
const filter: Record = {
to,
status: status as Twilio.Api.V2010.AccountContext.MessageListInstanceOptions['status']
};
// Twilio expects Date objects or ISO strings for date filter params
if (date_from) filter.dateSentAfter = new Date(date_from);
if (date_to) filter.dateSentBefore = new Date(date_to);
const messages = await client.messages.list({
...filter,
limit: page_size
});
return {
content: [{
type: 'text',
text: JSON.stringify(
messages.map(m => ({
sid: m.sid,
to: m.to,
from: m.from,
body: m.body,
status: m.status,
direction: m.direction,
date_sent: m.dateSent,
price: m.price,
price_unit: m.priceUnit
})),
null, 2
)
}]
};
});
server.tool('list_twilio_calls', {
date_from: z.string().optional(),
date_to: z.string().optional(),
to: z.string().optional(),
status: z.enum(['queued', 'ringing', 'in-progress', 'completed', 'failed', 'busy', 'no-answer', 'canceled']).optional(),
page_size: z.number().int().min(1).max(1000).default(50)
}, async ({ date_from, date_to, to, status, page_size }) => {
const calls = await client.calls.list({
to,
status: status as any,
startTimeAfter: date_from ? new Date(date_from) : undefined,
startTimeBefore: date_to ? new Date(date_to) : undefined,
limit: page_size
});
return {
content: [{
type: 'text',
text: JSON.stringify(
calls.map(c => ({
sid: c.sid,
to: c.to,
from: c.from,
status: c.status,
direction: c.direction,
duration: c.duration,
start_time: c.startTime,
price: c.price
})),
null, 2
)
}]
};
});
Checking number capabilities via the Lookup API
The Twilio Lookup API validates whether a number is real, identifies its line type (mobile, landline, VoIP), and checks SMS capability before sending. This prevents sending to invalid numbers (error 21211) and billing for undeliverable messages to landlines. The Lookup API has a separate base URL and optional add-ons like carrier lookup cost money per call.
server.tool('check_number_lookup', {
phone_number: z.string()
.regex(E164_REGEX, 'Phone number must be in E.164 format')
.describe('Phone number to look up.')
}, async ({ phone_number }) => {
// Lookup v2 uses a different URL: https://lookups.twilio.com/v2/PhoneNumbers/{number}
const lookup = await client.lookups.v2.phoneNumbers(phone_number).fetch({
fields: 'line_type_intelligence'
});
return {
content: [{
type: 'text',
text: JSON.stringify({
phone_number: lookup.phoneNumber,
national_format: lookup.nationalFormat,
country_code: lookup.countryCode,
valid: lookup.valid,
line_type: lookup.lineTypeIntelligence?.type,
// 'mobile', 'landline', 'voip', 'fixedVoip', 'tollFree', 'premium', 'ported'
mobile_network_code: lookup.lineTypeIntelligence?.mobileNetworkCode,
carrier_name: lookup.lineTypeIntelligence?.carrierName,
sms_capable: ['mobile', 'voip', 'fixedVoip'].includes(
lookup.lineTypeIntelligence?.type ?? ''
)
}, null, 2)
}]
};
});
Wiring /health/twilio via account fetch
The health probe for Twilio fetches the account resource via GET /Accounts/{AccountSid}. A successful response confirms the Account SID is valid, the Auth Token is correct, and the account is active. The response includes status which can be active, suspended, or closed — monitoring this field catches account suspension before it silently stops message delivery.
app.get('/health/twilio', async (req, res) => {
try {
const account = await client.api.accounts(process.env.TWILIO_ACCOUNT_SID!).fetch();
if (account.status !== 'active') {
return res.status(503).json({
status: 'unhealthy',
account_status: account.status,
error: `Twilio account is ${account.status} — messaging disabled`
});
}
return res.json({
status: 'healthy',
account_sid: account.sid,
account_name: account.friendlyName,
account_status: account.status,
date_created: account.dateCreated
});
} catch (err: any) {
// Twilio SDK errors include err.status (HTTP) and err.code (Twilio error code)
return res.status(503).json({
status: 'unhealthy',
http_status: err.status,
twilio_code: err.code,
error: err.message
});
}
});
| HTTP status | Twilio code | Meaning |
|---|---|---|
| 401 | 20003 | Authentication failed — wrong Account SID or Auth Token |
| 403 | 20004 | Access forbidden — account suspended or feature disabled |
| 404 | 20404 | Resource not found — Account SID doesn't exist |
| 429 | 20429 | Too many requests — back off and retry with exponential backoff |
Frequently asked questions
Why does Twilio return HTTP 201 instead of 200 for successful message sends?
The Twilio REST API follows HTTP semantics strictly for resource creation — POST requests that create a new resource return 201 Created with the created resource in the response body. The Twilio helper library handles this transparently and surfaces the message object regardless. If you're using raw axios or fetch, check for status === 201 as the success condition for messages.create, not status === 200. The 2010-04-01 API (the base REST layer) consistently uses 201 for all resource creation endpoints.
What's the difference between the primary Auth Token and API Keys?
The primary Auth Token is your account's master credential — if compromised, you must rotate it across all integrations simultaneously. API Keys (SK...) are secondary credentials scoped to the same account. You can create multiple API Keys, restrict their capabilities, and revoke individual keys without affecting others. For MCP servers, always use API Key + Secret pairs: set TWILIO_API_KEY and TWILIO_API_SECRET alongside TWILIO_ACCOUNT_SID, then initialize the client as Twilio(apiKey, apiSecret, { accountSid }). This way a leaked server secret only requires revoking one key, not rotating your entire account.
How do I receive delivery status updates for sent messages?
Set a StatusCallback URL when sending each message: this is a webhook URL Twilio will POST to as the message transitions through states (queued → sending → sent → delivered or failed). The webhook payload includes MessageSid, MessageStatus, and ErrorCode. For MCP servers that need delivery confirmation, implement a simple HTTP endpoint that records status updates to your database. Do not poll the message resource repeatedly — Twilio's webhooks are the intended delivery-status mechanism and don't count against rate limits.
How do I handle Twilio geographic permissions to avoid 21408 errors?
By default, Twilio restricts which countries your account can send SMS to, preventing unauthorized international charges. Enable specific country permissions in the Twilio Console under Phone Numbers → Regulatory Compliance → Geographic Permissions. For MCP tools serving international use cases, validate the destination country code from the phone number before calling the API and surface a clear error if the region is not enabled, rather than letting the 21408 error propagate. The Lookup API can identify the countryCode of a number before you attempt to send.
Further reading
- MCP Server SendGrid — transactional email, bounce management, and health probes via /scopes
- MCP Server PagerDuty — create incidents, manage on-call schedules, and health probes via /users/me
- MCP Server Slack — Block Kit messages, cursor pagination, and health probes via auth.test
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- MCP Server Error Handling — structured errors and retry patterns