Twilio SMS con Claude Code: notificaciones, Verify y Webhooks en producción
Implementa Twilio SMS con Claude Code: E.164, consentimiento, idempotencia, reintentos, Verify y callbacks.
SMS sigue siendo útil cuando el usuario no tiene la app abierta. Sirve para envíos, recordatorios de citas, alertas de incidentes, verificación de cuenta y respuestas urgentes de soporte.
El problema es que una integración de Twilio SMS parece más pequeña de lo que es. Un ejemplo mínimo puede llamar a la API y mostrar un Message SID, pero producción exige validar teléfonos, registrar consentimiento, evitar duplicados, reintentar con cuidado, recibir status callbacks, validar firmas de Webhook y no filtrar datos personales en logs.
Esta guía muestra cómo pedirle a Claude Code una integración práctica con Express + TypeScript. Incluye SMS saliente, Twilio Verify, callbacks de estado, idempotencia, reintentos, logging, seguridad y fallos habituales. Para el contexto relacionado, revisa implementación de autenticación, implementación de Webhooks y gestión de secretos.
Twilio SMS en palabras sencillas
Twilio da a tu aplicación una API de comunicación. Tu backend pide a Twilio: envía este texto, desde este remitente, a este número. Twilio entrega el mensaje a la red de operadores y devuelve un Message SID, que después sirve para soporte y trazabilidad.
Los números deben enviarse en formato E.164: signo más, código de país y número, por ejemplo +15558675310 o +819012345678. Piensa en E.164 como el formato seguro para API, no como la forma local que ve el usuario. La referencia oficial es la guía de Twilio sobre formato internacional de números.
La respuesta inicial de la API no es el final del flujo. Un SMS puede pasar por queued, sent, delivered, undelivered o failed. Twilio puede avisar esos cambios a tu endpoint de status callback. Antes de adaptar el código, abre la documentación de Programmable Messaging, el tutorial de SMS con Node.js, Messaging Webhooks y la guía de outbound status callbacks.
Casos de uso realistas
No empieces con una función genérica para “enviar cualquier SMS”. Empieza por el evento de negocio y sus reglas de fallo.
| Caso | Por qué SMS ayuda | Riesgo a revisar |
|---|---|---|
| Pedido y envío | El cliente puede no leer email, pero necesita estado | URL de tracking incorrecta, duplicados, baja |
| Recordatorio de cita | Reduce ausencias y confusión de último minuto | Zona horaria, horario sensible, consentimiento |
| Alertas de incidentes | Llega a guardias fuera de Slack o email | Tormenta de alertas, límites, escalado |
| Login y 2FA | Protege cuentas | Preferir Twilio Verify a OTP casero |
| Confirmación de soporte | El usuario sabe que se recibió su solicitud | No poner datos sensibles en el cuerpo |
Precios, países soportados, registro de remitentes, normas tipo A2P y obligaciones legales cambian. Este artículo no fija esos datos. Comprueba la consola de Twilio, la documentación vigente y el criterio legal o de cumplimiento antes de lanzar.
Prompt para Claude Code
Pide comportamiento operativo, no solo una llamada a API.
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
Idempotencia significa que el mismo evento puede repetirse sin causar otro efecto externo. En SMS importa mucho: una cola, un Webhook reenviado, una tarea batch o una acción manual de soporte pueden repetir el mismo evento.
flowchart LR
A["Cambio de pedido"] --> B["Control de idempotencia"]
B --> C["Twilio Messaging API"]
C --> D["Entrega SMS"]
C --> E["Guardar Message SID"]
D --> F["Status Callback"]
F --> G["Validar firma"]
G --> H["Actualizar log de entrega"]
I["Verificación de login"] --> J["Twilio Verify"]
Crear el proyecto mínimo
Este proyecto se puede copiar y ejecutar. Sin credenciales reales de Twilio, el envío no terminará correctamente, pero sí puedes probar variables de entorno, validación, duplicados y callbacks locales.
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 debe ser una URL HTTPS accesible desde Twilio. Para desarrollo local puedes usar ngrok o Cloudflare Tunnel. La validación de firma depende de la URL exacta, así que revisa protocolo, proxy, query string y slash final.
Implementar SMS, idempotencia y callbacks
Crea src/app.ts y pega este código. El demo usa un Map en memoria; producción debería usar PostgreSQL, Redis, DynamoDB u otro almacén durable con una restricción única para la clave de idempotencia.
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}`);
});
Arranca el servidor y envía una solicitud. Para entrega real necesitas credenciales válidas de Twilio, remitente, callback público y un destino permitido para tu cuenta.
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"
}'
Si repites el mismo eventId, la API devuelve el estado existente en lugar de enviar otro SMS. En producción, guarda ese estado en una base durable.
Para probar solo la forma del callback local, puedes poner temporalmente REQUIRE_TWILIO_SIGNATURE=false. En producción debe seguir en true.
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"
Usar Twilio Verify para OTP
Para login y 2FA, no empieces generando seis dígitos por tu cuenta. OTP implica caducidad, límites de reenvío, defensa contra fuerza bruta, canales, cambios de número y auditoría. Twilio Verify y la Verification API cubren esta necesidad.
Añade este código antes de app.listen en 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 });
});
Después de una verificación aprobada, actualiza tu propia tabla de usuarios con campos como phoneVerifiedAt o mfaEnabledAt. Para el límite completo de autenticación, combina esto con la guía de autenticación y la guía de Zod.
Consentimiento, compliance y seguridad
SMS llega directamente a un teléfono personal. Registra qué aceptó recibir el usuario, dónde lo aceptó y cómo se gestionan bajas o supresiones. Los requisitos cambian por país, tipo de remitente y contenido, así que usa la documentación vigente de Twilio y revisión legal.
No pegues Account SID, Auth Token, OTP, teléfonos completos ni cuerpos completos en código, prompts, capturas o logs. .env no debe entrar en Git; producción debe inyectar secretos desde la plataforma o un gestor de secretos. La guía de secrets management ayuda a cerrar ese flujo.
Los logs suelen necesitar Message SID, event ID, tipo de mensaje, teléfono enmascarado, código de error de Twilio, intentos y timestamp. Si debes conservar el cuerpo del mensaje, define antes retención, acceso y borrado.
Fallos habituales
Los errores más comunes son enviar números locales sin E.164, permitir que una cola reintente y duplique SMS, recibir callbacks públicos sin firma, construir OTP propio sin controles, y tener logs que dicen “falló” sin Message SID ni error code. Para la parte asíncrona revisa sistemas de cola y para defensa general seguridad con Claude Code.
Prompt de revisión
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
Este tema también funciona como contenido monetizable porque el lector necesita más que una función: necesita auth, colas, Webhooks, logs, CLAUDE.md y revisión. ClaudeCodeLab puede ayudar a convertirlo en un flujo real de repositorio con formación y consultoría Claude Code.
Resumen
Twilio SMS empieza con una llamada corta, pero la calidad real depende de E.164, consentimiento, idempotencia, reintentos, validación de callback y logs privados. Dale esos requisitos a Claude Code desde el primer prompt y revisa el resultado como una integración operativa.
En la comprobación práctica de este artículo, el flujo local validó E.164, el manejo de eventId duplicado, el parsing del Status Callback y los logs enmascarados. La entrega real depende de credenciales de Twilio, remitente, país destino y reglas actuales de Twilio; antes de publicar, prueba con un número controlado y sigue el Message SID hasta el callback.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.