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

Know when your Twilio credentials expire or your account gets suspended before messages stop delivering

AliveMCP polls your /health/twilio endpoint and alerts you the moment the account fetch fails — catching invalid credentials, suspended accounts, and API outages before your SMS pipeline goes silent.

Start monitoring free