← All posts
·16 min read

Claude Code with Twilio: SMS and Voice That Survive Production

Claude CodeTwilioSMSAPI
Claude Code with Twilio: SMS and voice integration without surprises

Why Twilio without CLAUDE.md fails in production

Twilio is the default SMS, MMS, and voice API for backend developers in 2026. The SDK is mature, the docs are thorough, and a "send a text message" call is six lines of code. The problem is that the six lines that work in development do not work in production, and Claude Code does not know which lines are negotiable and which are not. Without explicit constraints, Claude generates Twilio integrations that pass a unit test, deliver a message to a single dev phone, and then fail in five different ways as soon as they touch real traffic.

The most common Claude defaults that break Twilio in production: hardcoding the Account SID and Auth Token instead of reading from environment variables, instantiating a new Twilio client at the top of every file, ignoring the asynchronous status field returned by messages.create(), omitting webhook signature validation on the inbound SMS endpoint, sending to US numbers without A2P 10DLC registration, and treating Twilio error codes as generic exceptions instead of branching on the specific error number. On top of those, Claude gets the SDK shape wrong in subtle ways. The from and to fields require E.164 formatted numbers with a leading +, but Claude often generates examples with bare digits or locally formatted strings.

This guide covers the CLAUDE.md configuration that locks Claude Code into Twilio's correct model: a singleton client, environment-scoped credentials, mandatory webhook signature validation, A2P 10DLC awareness for US-bound traffic, structured error code handling, and the opt-out semantics that keep your sender numbers in good standing. If you are building a Next.js application and want the broader request lifecycle that your SMS sends sit inside, Claude Code with Next.js covers the route handler patterns. For email confirmations triggered by the same events, Claude Code with Resend pairs naturally with Twilio for multi-channel notifications.

The Twilio CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Twilio integration it needs to declare: the SDK version, the environment variable names for the credentials, the mandatory format for phone numbers, the response handling pattern, the webhook validation rule, and the hard rules that block the most common failure modes.

# Twilio integration rules

## Stack
- twilio ^5.x, TypeScript 5.x strict
- Node.js 20.x (or Next.js 14.x API routes / route handlers)
- TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER in .env.local
- TWILIO_MESSAGING_SERVICE_SID for A2P 10DLC compliant US sends (preferred over from:)

## Project structure
- src/lib/twilio.ts            , Twilio client singleton
- src/lib/sms/                 , SMS sending helpers
- src/lib/voice/               , Voice TwiML generators
- src/app/api/webhooks/twilio/ , inbound webhook handlers

## Client initialisation
- ALWAYS use a singleton: import { twilio } from '@/lib/twilio'
- src/lib/twilio.ts content:
  import Twilio from 'twilio';
  export const twilio = Twilio(
    process.env.TWILIO_ACCOUNT_SID,
    process.env.TWILIO_AUTH_TOKEN,
  );
- NEVER instantiate new Twilio() inline in route handlers

## Phone number format (MANDATORY)
- ALL phone numbers MUST be E.164: leading +, country code, digits, no spaces
- Correct:   +14155552671
- Incorrect: 415-555-2671, (415) 555-2671, 4155552671
- Validate inbound numbers with libphonenumber-js before storing

## Send pattern
Every twilio.messages.create() MUST include:
- to: E.164 formatted string
- body: string (or mediaUrl: string[] for MMS)
- One of: messagingServiceSid (preferred), from (fallback)

ALWAYS check the returned message.status and message.errorCode after create:
- queued | sending | sent: in flight, no action needed
- delivered: confirmed delivery (only available via status webhook)
- failed | undelivered: log errorCode and branch

## Hard rules
- NEVER hardcode TWILIO_AUTH_TOKEN in source files or commit it
- NEVER skip webhook signature validation on inbound endpoints
- NEVER send to US numbers without an A2P 10DLC registered Messaging Service
- NEVER ignore the errorCode field on failed messages
- NEVER use raw twilio.request() when the typed SDK has a method
- NEVER store phone numbers in any format other than E.164
- ALWAYS handle STOP, START, HELP keywords on inbound messages

Four rules here prevent the majority of production failures Claude generates without them.

The E.164 format rule is the most common silent failure. The Twilio SDK does not strictly validate phone number format at send time. A request with to: '415-555-2671' will be accepted, charged, and then fail asynchronously with error code 21211 or 21614. Without the format rule in CLAUDE.md, Claude will copy whatever format appears in the first prompt and propagate it through helpers, tests, and DB schemas.

The Messaging Service vs from field rule matters specifically for US traffic. Since A2P 10DLC enforcement tightened in 2024, sending from a raw long code phone number to a US destination without a registered Messaging Service is either filtered by carriers or rejected with error code 30007 or 30034. Claude defaults to the from: field because the Twilio quickstart docs use it. Forcing messagingServiceSid: in CLAUDE.md keeps Claude on the path that works for US compliance.

The webhook signature validation rule is a security boundary. An unauthenticated webhook endpoint that accepts inbound SMS or status callbacks is a public attack surface. Claude omits signature validation by default because it is not required for the webhook to receive requests. Twilio signs every webhook with the Auth Token, and the SDK includes a helper to verify the signature. The CLAUDE.md rule makes Claude include the helper in every webhook handler it generates.

The error code branching rule turns Twilio failures from opaque exceptions into actionable logs. Twilio publishes a complete error code catalogue. A 21211 means the destination number is invalid. A 30003 means the destination is unreachable. A 30007 means the carrier filtered the message. Treating them all as a single thrown exception hides the failure mode. The rule in CLAUDE.md makes Claude branch on errorCode in the catch block.

Install and credential setup

Install the Twilio Node SDK:

npm i twilio
npm i -D @types/node

Add the credentials to your environment file:

# .env.local
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
TWILIO_PHONE_NUMBER=+14155552671
TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create the singleton client every send in the project imports from:

// src/lib/twilio.ts
import Twilio from 'twilio';

if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) {
  throw new Error('Twilio credentials are not configured');
}

export const twilio = Twilio(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN,
);

export const TWILIO_PHONE = process.env.TWILIO_PHONE_NUMBER ?? '';
export const TWILIO_MESSAGING_SERVICE_SID = process.env.TWILIO_MESSAGING_SERVICE_SID ?? '';

The startup check fails fast at boot if credentials are missing, rather than at the first send attempt hours into a deployment. The Twilio SDK does not enforce this check internally, so Claude omits it by default unless CLAUDE.md includes it in the singleton example.

The SMS send pattern

A typed wrapper that every SMS in the project flows through:

// src/lib/sms/send.ts
import { twilio, TWILIO_MESSAGING_SERVICE_SID } from '@/lib/twilio';

interface SendSmsOptions {
  to: string;          // E.164 format
  body: string;
  mediaUrl?: string[]; // optional, makes it an MMS
}

export async function sendSms(options: SendSmsOptions) {
  if (!options.to.startsWith('+')) {
    throw new Error(`Phone number must be E.164 format with leading +: ${options.to}`);
  }

  try {
    const message = await twilio.messages.create({
      messagingServiceSid: TWILIO_MESSAGING_SERVICE_SID,
      to: options.to,
      body: options.body,
      mediaUrl: options.mediaUrl,
      statusCallback: 'https://yourdomain.com/api/webhooks/twilio/status',
    });

    return {
      sid: message.sid,
      status: message.status,
      to: message.to,
    };
  } catch (err) {
    const error = err as { code?: number; status?: number; message?: string };
    console.error('[Twilio] Send failed:', {
      code: error.code,
      status: error.status,
      message: error.message,
      to: options.to,
    });
    throw err;
  }
}

Calling this from a Next.js route handler:

// src/app/api/notifications/sms/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendSms } from '@/lib/sms/send';

export async function POST(req: NextRequest) {
  const { to, body } = await req.json();

  const result = await sendSms({ to, body });

  return NextResponse.json({ ok: true, messageSid: result.sid });
}

Three things this wrapper does that Claude omits without CLAUDE.md guidance. First, it validates E.164 format before calling Twilio, which surfaces the format error in your stack trace rather than as an opaque async failure. Second, it always sets a statusCallback URL, which is how you find out whether the message was actually delivered (the messages.create response only confirms that Twilio accepted the request). Third, it logs the structured error fields (code, status, message) when a send fails, rather than dropping into a generic catch block that swallows the diagnostic information.

Webhook signature validation

Twilio signs every webhook it sends with HMAC-SHA1 over the request URL and parameters, using your Auth Token as the secret. Validating the signature confirms the webhook came from Twilio and not from a third party with a copy of your endpoint URL. Skipping validation is the most common Twilio security mistake.

// src/app/api/webhooks/twilio/sms/route.ts
import { NextRequest, NextResponse } from 'next/server';
import twilio from 'twilio';

const authToken = process.env.TWILIO_AUTH_TOKEN ?? '';

export async function POST(req: NextRequest) {
  const signature = req.headers.get('x-twilio-signature');
  if (!signature) {
    return new NextResponse('Missing signature', { status: 403 });
  }

  const url = req.url;
  const formData = await req.formData();
  const params: Record<string, string> = {};
  formData.forEach((value, key) => {
    params[key] = String(value);
  });

  const isValid = twilio.validateRequest(authToken, signature, url, params);
  if (!isValid) {
    return new NextResponse('Invalid signature', { status: 403 });
  }

  const from = params.From;
  const body = params.Body?.trim().toUpperCase();

  // Handle opt-out keywords
  if (body === 'STOP' || body === 'UNSUBSCRIBE' || body === 'CANCEL') {
    await markPhoneOptedOut(from);
    // Twilio auto-replies with STOP confirmation, do not send your own
    return new NextResponse('<Response></Response>', {
      headers: { 'Content-Type': 'text/xml' },
    });
  }

  if (body === 'START' || body === 'YES' || body === 'UNSTOP') {
    await markPhoneOptedIn(from);
    return new NextResponse('<Response></Response>', {
      headers: { 'Content-Type': 'text/xml' },
    });
  }

  if (body === 'HELP' || body === 'INFO') {
    const helpResponse = '<Response><Message>Claudify: reply STOP to opt out. Support: support@claudify.tech</Message></Response>';
    return new NextResponse(helpResponse, {
      headers: { 'Content-Type': 'text/xml' },
    });
  }

  // Application-specific handling
  await handleInboundSms({ from, body: params.Body });

  return new NextResponse('<Response></Response>', {
    headers: { 'Content-Type': 'text/xml' },
  });
}

async function markPhoneOptedOut(phone: string) {
  // update DB suppression list
}

async function markPhoneOptedIn(phone: string) {
  // re-enable sends to this phone
}

async function handleInboundSms(message: { from: string; body: string }) {
  // application logic
}

Add a webhook section to CLAUDE.md:

## Webhooks

- Endpoint: src/app/api/webhooks/twilio/*/route.ts
- ALWAYS validate signature with twilio.validateRequest() before processing
- Parse form data (Twilio webhooks use application/x-www-form-urlencoded, not JSON)
- Return TwiML XML responses with Content-Type: text/xml
- Handle STOP, UNSUBSCRIBE, CANCEL: mark opted out, return empty <Response>
- Handle START, YES, UNSTOP: mark opted in, return empty <Response>
- Handle HELP, INFO: return <Message> with support contact
- NEVER reply with a custom STOP confirmation, Twilio sends one automatically
- Return 200 OK with empty <Response> for events you do not act on
- Return 403 for invalid signatures

The opt-out keyword handling is regulatory, not optional. The Telephone Consumer Protection Act in the US, GDPR in the EU, and equivalent regulations elsewhere require honoring opt-out requests immediately. Twilio automatically sends a STOP confirmation message back to the user. Your application must mark the phone as opted out in your own database so subsequent sends do not attempt to deliver to a suppressed number. Sending to a STOP-suppressed number returns error code 21610 and counts against your account's compliance score.

For status callbacks, set up a second endpoint:

// src/app/api/webhooks/twilio/status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import twilio from 'twilio';

const authToken = process.env.TWILIO_AUTH_TOKEN ?? '';

export async function POST(req: NextRequest) {
  const signature = req.headers.get('x-twilio-signature') ?? '';
  const url = req.url;
  const formData = await req.formData();
  const params: Record<string, string> = {};
  formData.forEach((v, k) => { params[k] = String(v); });

  if (!twilio.validateRequest(authToken, signature, url, params)) {
    return new NextResponse('Invalid signature', { status: 403 });
  }

  const messageSid = params.MessageSid;
  const status = params.MessageStatus;
  const errorCode = params.ErrorCode;

  if (status === 'failed' || status === 'undelivered') {
    await recordMessageFailure(messageSid, errorCode);
  } else if (status === 'delivered') {
    await recordMessageDelivered(messageSid);
  }

  return new NextResponse('', { status: 200 });
}

async function recordMessageFailure(sid: string, code: string) {
  // log the error code, alert on persistent failures
}

async function recordMessageDelivered(sid: string) {
  // mark message as delivered in DB
}

A2P 10DLC compliance for US traffic

A2P 10DLC (Application-to-Person 10-Digit Long Code) is the registration framework that US carriers require for application-driven SMS traffic. Sending to US numbers without an approved Brand and Campaign through a registered Messaging Service produces filtered messages, error code 30007, and damaged sender reputation.

The compliance path:

  1. Register your Brand in the Twilio console (business details, EIN, address)
  2. Submit a Campaign that describes the use case (transactional notifications, OTP, marketing alerts)
  3. Wait for carrier approval (typically 1 to 3 business days)
  4. Add long code numbers to a Messaging Service tied to the approved Campaign
  5. Send all US-bound traffic through messagingServiceSid, never raw from

Get Claudify. The bundled Twilio CLAUDE.md ships with the A2P 10DLC compliance rules pre-configured, including the Messaging Service pattern and the throughput limits each campaign tier supports.

Add A2P 10DLC rules to CLAUDE.md:

## A2P 10DLC (US traffic)

- All US-bound SMS MUST use messagingServiceSid, NEVER from:
- TWILIO_MESSAGING_SERVICE_SID points to an A2P 10DLC registered Messaging Service
- Throughput limits depend on Campaign tier:
  - Standard: 1 segment/sec
  - Sole proprietor: 75 segments/sec
  - Low volume: 75 segments/sec
  - High volume: 225 segments/sec
- Error code 30007: message filtered by carrier (campaign mismatch, content flagged)
- Error code 30034: A2P 10DLC registration missing or rejected
- Never send marketing content through a transactional campaign or vice versa

For non-US destinations, the from: field with a sender ID or long code works without A2P 10DLC. If your application sends both US and international traffic, use the Messaging Service for everything (it routes correctly internationally without the compliance overhead).

Error code handling

Twilio publishes a comprehensive error code catalogue. Handling the common ones explicitly turns failed sends from opaque exceptions into structured outcomes your application can react to.

// src/lib/sms/error-codes.ts
export const TWILIO_ERROR_CATEGORIES = {
  INVALID_NUMBER: [21211, 21217, 21218, 21610],
  CARRIER_FILTERED: [30003, 30005, 30006, 30007, 30008],
  A2P_REGISTRATION: [30034, 30038],
  RATE_LIMITED: [20429],
  AUTH_FAILURE: [20003],
  GEO_PERMISSION: [21408],
} as const;

interface TwilioError {
  code?: number;
  message?: string;
}

export function categorizeTwilioError(err: TwilioError) {
  const code = err.code;
  if (!code) return 'unknown';

  for (const [category, codes] of Object.entries(TWILIO_ERROR_CATEGORIES)) {
    if ((codes as readonly number[]).includes(code)) {
      return category.toLowerCase();
    }
  }

  return 'other';
}

Using the categorization in the send wrapper:

import { categorizeTwilioError } from '@/lib/sms/error-codes';

try {
  await sendSms({ to, body });
} catch (err) {
  const category = categorizeTwilioError(err as { code?: number });

  if (category === 'invalid_number') {
    await markPhoneInvalid(to);
  } else if (category === 'a2p_registration') {
    await alertCompliance('A2P 10DLC registration issue');
  } else if (category === 'geo_permission') {
    await alertOps(`Geographic permission missing for ${to}`);
  } else if (category === 'rate_limited') {
    await retryWithBackoff(() => sendSms({ to, body }));
  } else {
    await logUnknownError(err);
  }
}

Common Twilio error codes to know:

Code Meaning Action
21211 Invalid to number (not E.164) Validate format upstream
21217 Number is not a valid mobile phone Suppress in DB
21610 Recipient previously opted out (STOP) Suppress, do not retry
30003 Unreachable destination Retry later, then suppress
30007 Carrier filtered (A2P 10DLC content mismatch) Review campaign use case
30034 A2P 10DLC registration missing Complete Brand and Campaign
20429 Account rate limit exceeded Backoff and retry
21408 No geographic permission for region Enable region in console

TwiML for voice

Twilio's voice API uses TwiML, an XML dialect that describes the call flow. Generating TwiML in a Next.js route handler:

// src/app/api/webhooks/twilio/voice/route.ts
import { NextRequest, NextResponse } from 'next/server';
import twilio from 'twilio';

const VoiceResponse = twilio.twiml.VoiceResponse;
const authToken = process.env.TWILIO_AUTH_TOKEN ?? '';

export async function POST(req: NextRequest) {
  const signature = req.headers.get('x-twilio-signature') ?? '';
  const url = req.url;
  const formData = await req.formData();
  const params: Record<string, string> = {};
  formData.forEach((v, k) => { params[k] = String(v); });

  if (!twilio.validateRequest(authToken, signature, url, params)) {
    return new NextResponse('Invalid signature', { status: 403 });
  }

  const response = new VoiceResponse();
  response.say(
    { voice: 'Polly.Amy', language: 'en-GB' },
    'Welcome to Claudify support. Please press 1 for billing, or 2 for technical issues.',
  );
  response.gather({
    numDigits: 1,
    action: '/api/webhooks/twilio/voice/menu',
    method: 'POST',
  });

  return new NextResponse(response.toString(), {
    headers: { 'Content-Type': 'text/xml' },
  });
}

Add a voice section to CLAUDE.md:

## Voice (TwiML)

- Use twilio.twiml.VoiceResponse, not raw XML strings
- Always set voice and language on say() for consistent output
- Use gather() with action and method for menu input
- Return Content-Type: text/xml on all voice webhook responses
- Validate signature with twilio.validateRequest() before processing
- Use record() with maxLength to cap voicemail duration

Verify API for OTP

Twilio Verify is the recommended OTP service. It handles delivery, expiration, rate limiting, and retries. Implementing OTP through messages.create() directly is technically possible but pushes the compliance work onto your code.

// src/lib/verify/send-otp.ts
import { twilio } from '@/lib/twilio';

const verifyServiceSid = process.env.TWILIO_VERIFY_SERVICE_SID ?? '';

export async function sendOtp(toPhone: string) {
  const verification = await twilio.verify.v2
    .services(verifyServiceSid)
    .verifications
    .create({ to: toPhone, channel: 'sms' });

  return { status: verification.status, sid: verification.sid };
}

export async function checkOtp(toPhone: string, code: string) {
  const check = await twilio.verify.v2
    .services(verifyServiceSid)
    .verificationChecks
    .create({ to: toPhone, code });

  return check.status === 'approved';
}

Add a Verify section to CLAUDE.md:

## Verify (OTP)

- TWILIO_VERIFY_SERVICE_SID in .env.local for the configured Verify Service
- Use channel: 'sms' for text-based OTP, 'call' for voice OTP, 'whatsapp' for WhatsApp
- verificationChecks.create() returns status: approved | pending | canceled
- approved means the code matched and the phone is verified
- Do not roll your own OTP through messages.create() unless requirements forbid Verify
- Verify handles rate limiting (5 OTPs per phone per 10 min by default)

Common Claude Code mistakes with Twilio

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Inline client instantiation

Claude generates: const client = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); at the top of every file.

Correct pattern: one singleton at src/lib/twilio.ts, imported everywhere.

2. Bare digit phone numbers

Claude generates: to: '4155552671' or to: '415-555-2671'.

Correct pattern: to: '+14155552671' (E.164 with leading +).

3. from: instead of messagingServiceSid: for US

Claude generates: from: process.env.TWILIO_PHONE_NUMBER for US-bound sends.

Correct pattern: messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID for A2P 10DLC compliance.

4. No signature validation

Claude generates: a webhook handler that parses req.body and acts on the data.

Correct pattern: twilio.validateRequest(authToken, signature, url, params) before any business logic.

5. Generic error catch

Claude generates: catch (err) { console.error(err); }.

Correct pattern: extract err.code, categorize against TWILIO_ERROR_CATEGORIES, branch on the result.

6. No STOP handling

Claude generates: inbound webhook that always returns the same response.

Correct pattern: check for STOP, UNSUBSCRIBE, CANCEL keywords, suppress the number, return empty TwiML.

Add these as before/after pairs in CLAUDE.md. Claude matches concrete patterns more reliably than abstract rules.

Permission hooks for Twilio scripts

A Twilio project accumulates scripts: seed scripts that send test SMS, batch scripts that broadcast to user segments, suppression list managers, voice call generators. Some are read-only. Some trigger paid Twilio calls. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(node scripts/check-twilio-config.js*)",
      "Bash(node scripts/preview-twiml.js*)",
      "Bash(node scripts/lookup-number.js*)"
    ],
    "deny": [
      "Bash(node scripts/send-broadcast.js*)",
      "Bash(node scripts/place-call.js*)",
      "Bash(node scripts/purge-suppression-list.js*)"
    ]
  }
}

Checking config and previewing TwiML produce no external Twilio calls. Broadcasting or placing voice calls bills your account. The deny list forces Claude to surface those operations as prompts rather than running them as part of automated workflow. For more on hook patterns, Claude Code hooks covers the full configuration model.

Building Twilio integrations that survive production

The Twilio CLAUDE.md in this guide produces SMS and voice integrations where every number is E.164, every US-bound send uses a registered Messaging Service, every webhook is signature-validated, every error code is categorized, opt-out keywords are honored, and the singleton client is the only Twilio instance in the project.

The underlying principle is the same as any third party API with Claude Code. Twilio without a CLAUDE.md produces code that compiles, runs in development, and then fails in subtle ways: messages filtered by carriers, webhooks accepting forged requests, OTPs that never arrive, broadcasts that opt-out users receive anyway. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.

For the broader notification layer, Twilio pairs with Claude Code with Resend for multi-channel transactional flows (SMS for time-sensitive, email for content-rich), and with Claude Code with Stripe for payment confirmation SMS. Get Claudify. The bundled Twilio template ships pre-configured with all six common-mistake rules and the A2P 10DLC compliance path.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame