Claude Code and Twilio SMS: Production Notifications, Verify, and Webhooks
Build Twilio SMS with Claude Code: E.164 validation, consent, idempotency, retries, Verify, and status callbacks.
SMS is one of the few notification channels that still reaches people when they are not inside your app. It is useful for shipping updates, appointment reminders, incident alerts, account verification, and urgent support replies.
The implementation looks deceptively small. A thin demo can call Twilio once and print a Message SID. A production integration needs much more: phone number validation, user consent, duplicate prevention, retries, status callbacks, webhook signature checks, and logs that do not leak personal data.
This guide shows how to ask Claude Code for a practical Twilio SMS integration and how to review the result. The examples use Express + TypeScript and cover outbound SMS, Twilio Verify, status callbacks, idempotency, retry behavior, logging, and operational pitfalls. For adjacent work, pair this with authentication implementation, webhook implementation, and secrets management.
Twilio SMS in Plain Terms
Twilio gives your application a communication API. Your app sends Twilio a request that says: send this body, from this Twilio sender, to this phone number. Twilio then hands the message to the carrier network and returns a Message SID, which is the ID you use for support and delivery tracking.
The phone number should be in E.164 format: a plus sign, country code, and subscriber number, such as +15558675310 or +819012345678. Treat E.164 as the API-safe shape of a phone number, not as a UI format for humans. Use Twilio’s international number formatting guidance as the source of truth.
Delivery does not end at the first API response. SMS status can move through states such as queued, sent, delivered, undelivered, or failed. Twilio can call your status callback endpoint as these states change. Keep the official Programmable Messaging docs, Node.js SMS tutorial, Messaging Webhooks docs, and outbound status callback guide open while adapting this code.
Realistic Use Cases
Do not start with a generic “send any SMS” helper. Start with the business event and its failure rules.
| Use case | Why SMS helps | What to watch |
|---|---|---|
| Order and shipping updates | Customers may miss email but still need status changes | Wrong tracking URL, duplicate sends, opt-out handling |
| Appointment reminders | Reduces no-shows and last-minute confusion | Time zones, quiet hours, consent records |
| Incident or admin alerts | Reaches on-call staff outside Slack or email | Alert storms, rate limits, escalation rules |
| Login checks and 2FA | Helps protect accounts | Prefer Twilio Verify over homegrown OTP |
| Support acknowledgement | Confirms that a request was received | Avoid putting sensitive details in the body |
Pricing, supported countries, sender registration, A2P-style rules, and compliance obligations change over time. This article does not hard-code those claims. Check the current Twilio Console, official docs, and your legal or compliance owner before launch.
Prompt Claude Code With Production Requirements
Ask Claude Code for the operational behavior, not only the API call.
Implement Twilio SMS notifications in Express + TypeScript.
Requirements:
- Read Twilio credentials, sender number, and Verify Service SID from env vars
- Validate phone numbers in E.164 format with Zod
- Add POST /api/order-shipped-sms for order shipment SMS
- Use eventId as the idempotency key so duplicate events do not send twice
- Retry only 429 and 5xx-style transient failures
- Never log full phone numbers, full message bodies, Auth Tokens, or OTP codes
- Receive status callbacks at POST /twilio/status-callback
- Require Twilio signature validation in production
- Add Twilio Verify start/check endpoints
- Include .env.example, package.json, run commands, and curl examples
Idempotency means that the same business event can be retried safely. For SMS, this is not optional. Queue retries, webhook redeliveries, batch replays, and manual support actions can all hit the same event twice.
flowchart LR
A["Order update"] --> B["Idempotency check"]
B --> C["Twilio Messaging API"]
C --> D["SMS delivery"]
C --> E["Store Message SID"]
D --> F["Status Callback"]
F --> G["Signature check"]
G --> H["Delivery log update"]
I["Login check"] --> J["Twilio Verify"]
Create the Demo Project
The following project is intentionally small. Without real Twilio credentials, the outbound send will not succeed, but you can still validate environment parsing, request validation, duplicate handling, and local callback parsing.
mkdir twilio-sms-demo
cd twilio-sms-demo
npm init -y
npm install express twilio dotenv zod
npm install -D typescript tsx @types/express
{
"type": "module",
"scripts": {
"dev": "tsx src/app.ts"
},
"dependencies": {
"dotenv": "latest",
"express": "latest",
"twilio": "latest",
"zod": "latest"
},
"devDependencies": {
"@types/express": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
# .env.example
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=replace-with-your-auth-token
TWILIO_FROM_NUMBER=+15551234567
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PUBLIC_BASE_URL=https://example.ngrok-free.app
REQUIRE_TWILIO_SIGNATURE=true
PORT=3000
PUBLIC_BASE_URL must be an HTTPS URL that Twilio can reach. For local development, use a tunnel such as ngrok or Cloudflare Tunnel. Twilio signature validation is sensitive to the exact URL, so mismatched proxies, protocols, query strings, or trailing slashes can break verification.
Implement SMS, Idempotency, and Status Callbacks
Create src/app.ts and paste this code. The demo uses an in-memory Map; production should use PostgreSQL, Redis, DynamoDB, or another durable store with a unique constraint on the idempotency key.
import "dotenv/config";
import express from "express";
import twilio from "twilio";
import { z } from "zod";
const e164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, {
message: "Use E.164 format, for example +819012345678.",
});
const envSchema = z.object({
TWILIO_ACCOUNT_SID: z.string().regex(/^AC[a-fA-F0-9]{32}$/),
TWILIO_AUTH_TOKEN: z.string().min(20),
TWILIO_FROM_NUMBER: e164Schema,
TWILIO_VERIFY_SERVICE_SID: z.string().regex(/^VA[a-fA-F0-9]{32}$/).optional(),
PUBLIC_BASE_URL: z.string().url(),
REQUIRE_TWILIO_SIGNATURE: z.enum(["true", "false"]).default("true"),
PORT: z.coerce.number().int().positive().default(3000),
});
const env = envSchema.parse(process.env);
const client = twilio(env.TWILIO_ACCOUNT_SID, env.TWILIO_AUTH_TOKEN);
const app = express();
type Delivery = {
status: "pending" | "sent" | "failed";
attempts: number;
updatedAt: string;
sid?: string;
error?: string;
};
const deliveries = new Map<string, Delivery>();
const orderSmsSchema = z.object({
eventId: z.string().min(6).max(120),
phone: e164Schema,
orderId: z.string().min(1).max(80),
trackingUrl: z.string().url().optional(),
consentAt: z.string().datetime(),
});
const statusCallbackSchema = z.object({
MessageSid: z.string().min(2),
MessageStatus: z.string().min(2),
To: z.string().optional(),
ErrorCode: z.string().optional(),
}).passthrough();
function maskPhone(phone: string) {
return phone.replace(/\d(?=\d{4})/g, "*");
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getErrorStatus(error: unknown) {
if (typeof error === "object" && error && "status" in error) {
return Number((error as { status?: number }).status ?? 0);
}
return 0;
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function shouldRetry(error: unknown) {
const status = getErrorStatus(error);
return status === 429 || status >= 500;
}
async function sendSmsWithRetry(params: {
to: string;
body: string;
statusCallback: string;
maxAttempts?: number;
}) {
const maxAttempts = params.maxAttempts ?? 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const message = await client.messages.create({
body: params.body,
from: env.TWILIO_FROM_NUMBER,
statusCallback: params.statusCallback,
to: params.to,
});
return { sid: message.sid, attempts: attempt };
} catch (error) {
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
await delay(500 * attempt);
}
}
throw new Error("SMS retry loop ended unexpectedly.");
}
function verifyTwilioSignature(req: express.Request) {
const signature = req.header("x-twilio-signature") ?? "";
const callbackUrl = new URL(req.originalUrl, env.PUBLIC_BASE_URL).toString();
return twilio.validateRequest(env.TWILIO_AUTH_TOKEN, signature, callbackUrl, req.body);
}
app.use(express.json());
app.post("/api/order-shipped-sms", async (req, res) => {
const parsed = orderSmsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "invalid_request",
details: parsed.error.flatten(),
});
}
const input = parsed.data;
const idempotencyKey = `order-shipped:${input.eventId}`;
const existing = deliveries.get(idempotencyKey);
if (existing?.status === "sent") {
return res.status(200).json({
duplicate: true,
sid: existing.sid,
status: existing.status,
});
}
if (existing?.status === "pending") {
return res.status(202).json({
duplicate: true,
status: existing.status,
});
}
deliveries.set(idempotencyKey, {
attempts: 0,
status: "pending",
updatedAt: new Date().toISOString(),
});
const trackingText = input.trackingUrl ? ` Tracking: ${input.trackingUrl}` : "";
const body = `Your order ${input.orderId} has shipped.${trackingText}`;
const statusCallback = new URL("/twilio/status-callback", env.PUBLIC_BASE_URL).toString();
try {
const result = await sendSmsWithRetry({
body,
statusCallback,
to: input.phone,
});
deliveries.set(idempotencyKey, {
attempts: result.attempts,
sid: result.sid,
status: "sent",
updatedAt: new Date().toISOString(),
});
console.log("sms_sent", {
idempotencyKey,
sid: result.sid,
to: maskPhone(input.phone),
});
return res.status(202).json({ accepted: true, sid: result.sid });
} catch (error) {
deliveries.set(idempotencyKey, {
attempts: 3,
error: getErrorMessage(error),
status: "failed",
updatedAt: new Date().toISOString(),
});
console.error("sms_failed", {
idempotencyKey,
message: getErrorMessage(error),
status: getErrorStatus(error),
to: maskPhone(input.phone),
});
return res.status(502).json({ error: "sms_delivery_failed" });
}
});
app.post("/twilio/status-callback", express.urlencoded({ extended: false }), (req, res) => {
if (env.REQUIRE_TWILIO_SIGNATURE === "true" && !verifyTwilioSignature(req)) {
return res.status(403).send("invalid signature");
}
const parsed = statusCallbackSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).send("invalid callback");
}
console.log("twilio_status", {
errorCode: parsed.data.ErrorCode,
sid: parsed.data.MessageSid,
status: parsed.data.MessageStatus,
to: parsed.data.To ? maskPhone(parsed.data.To) : undefined,
});
return res.status(204).send();
});
app.listen(env.PORT, () => {
console.log(`Twilio SMS demo listening on http://localhost:${env.PORT}`);
});
Start the server and send a test request. Real delivery requires valid Twilio credentials, a sender, a reachable callback URL, and an allowed destination number for your account.
npm run dev
curl -X POST http://localhost:3000/api/order-shipped-sms \
-H "Content-Type: application/json" \
-d '{
"eventId": "order_1001_shipped_v1",
"phone": "+15558675310",
"orderId": "1001",
"trackingUrl": "https://example.com/track/1001",
"consentAt": "2026-06-02T09:00:00.000Z"
}'
Send the same eventId again and the API returns the existing state instead of sending a second SMS. In production, move this state to a durable database and protect it with a unique constraint.
For a local-only callback shape test, temporarily set REQUIRE_TWILIO_SIGNATURE=false. Keep it true outside local development.
curl -X POST http://localhost:3000/twilio/status-callback \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "MessageSid=SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
--data-urlencode "MessageStatus=delivered" \
--data-urlencode "To=+15558675310"
Use Twilio Verify for OTP
For login checks and two-factor authentication, do not start by generating six digits in your own database. OTP work includes expiration, resend limits, brute-force protection, channel behavior, abuse controls, and audit trails. Twilio Verify and the Verification API exist for this job.
Add the following code before app.listen in src/app.ts.
const verifyStartSchema = z.object({
phone: e164Schema,
});
const verifyCheckSchema = z.object({
code: z.string().min(4).max(10),
phone: e164Schema,
});
function requireVerifyServiceSid() {
if (!env.TWILIO_VERIFY_SERVICE_SID) {
throw new Error("TWILIO_VERIFY_SERVICE_SID is required for Verify.");
}
return env.TWILIO_VERIFY_SERVICE_SID;
}
app.post("/api/verify/start", async (req, res) => {
const parsed = verifyStartSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_request" });
}
const verification = await client.verify.v2
.services(requireVerifyServiceSid())
.verifications.create({
channel: "sms",
to: parsed.data.phone,
});
return res.status(202).json({ sid: verification.sid, status: verification.status });
});
app.post("/api/verify/check", async (req, res) => {
const parsed = verifyCheckSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_request" });
}
const check = await client.verify.v2
.services(requireVerifyServiceSid())
.verificationChecks.create({
code: parsed.data.code,
to: parsed.data.phone,
});
return res.json({ approved: check.status === "approved", status: check.status });
});
After Verify approves a code, update your own user record, such as phoneVerifiedAt or mfaEnabledAt. For the broader auth boundary, read the authentication guide and Zod validation guide.
Consent, Compliance, and Security
SMS goes directly to a personal phone number, so treat it as a sensitive channel. Record what the user agreed to receive, where they agreed, and how opt-out or suppression is handled. Requirements vary by country, sender type, and message content, so use the current Twilio guidance and legal review rather than copying fixed claims from an article.
Never paste a real Account SID/Auth Token pair into code, prompts, screenshots, logs, or article drafts. Keep .env out of Git and inject secrets through your hosting platform or secret manager. When prompting Claude Code, explicitly say that real credentials must not be printed and sample values must stay as placeholders. The secrets management guide covers the surrounding workflow.
Logs should not contain full phone numbers, full message bodies, or OTP codes. Usually you need Message SID, event ID, message type, masked phone number, Twilio error code, attempt count, and timestamp. If your business needs message-body retention, define retention, access, and deletion rules first.
Pitfalls to Review
First, domestic phone numbers such as 090-1234-5678 or (555) 867-5310 are not enough for an API. Normalize to E.164 before sending and show an example near the user input.
Second, queue retries can send duplicate SMS messages. Store an idempotency key such as order-shipped:eventId before sending. Use a durable store and a unique constraint. The same idea appears in the queue systems guide.
Third, status callbacks without signature validation are easy to spoof. If the endpoint is public, verify Twilio’s signature, use HTTPS, and return 403 for invalid requests.
Fourth, custom OTP looks easy until expiration, resend limits, brute-force controls, number changes, and audit logs appear. Prefer Verify unless you have a specific reason to own the OTP system.
Fifth, poor logs make support blind. “SMS failed” is not enough. Keep the Message SID, Twilio error code, destination country, retry count, and callback arrival state while masking personal data.
Claude Code Review Prompt
Use Claude Code again as a reviewer before shipping.
Review this Twilio SMS implementation before production.
Check:
- E.164 validation always runs before sending
- Consent timestamp and message purpose are tracked
- eventId idempotency holds under parallel requests
- Only 429 and 5xx transient failures are retried
- Twilio status callback signature validation is required in production
- Auth Tokens, OTP codes, full phone numbers, and full bodies never reach logs
- Pricing, countries, or regulatory rules are not hard-coded in comments
- Support can trace a failure by Message SID
This is also where a thin tutorial becomes monetizable. Readers who need SMS in a real product usually need help with auth, queues, logs, CLAUDE.md, and review gates. ClaudeCodeLab can turn this into a repository-specific workflow through Claude Code training and consultation.
Summary
Twilio SMS starts with a short API call, but production quality depends on phone number format, consent, duplicate prevention, retries, callback verification, and privacy-aware logs. Give Claude Code those requirements at the start, then review the implementation as an operational integration rather than a helper function.
In the hands-on check for this article, the local app shape verified E.164 validation, duplicate eventId handling, status callback parsing, and masked logs. Real SMS delivery still depends on valid Twilio credentials, sender setup, destination country configuration, and the current Twilio rules, so test with a small controlled number and trace the Message SID through the callback before launch.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.