Use Cases (Actualizado: 2/6/2026)

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.

Twilio SMS con Claude Code: notificaciones, Verify y Webhooks en producción

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.

CasoPor qué SMS ayudaRiesgo a revisar
Pedido y envíoEl cliente puede no leer email, pero necesita estadoURL de tracking incorrecta, duplicados, baja
Recordatorio de citaReduce ausencias y confusión de último minutoZona horaria, horario sensible, consentimiento
Alertas de incidentesLlega a guardias fuera de Slack o emailTormenta de alertas, límites, escalado
Login y 2FAProtege cuentasPreferir Twilio Verify a OTP casero
Confirmación de soporteEl usuario sabe que se recibió su solicitudNo 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.

#Claude Code #Twilio #SMS #notificaciones #integración API
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.