Twilio SMS avec Claude Code : notifications, Verify et Webhooks en production
Implémentez Twilio SMS avec Claude Code : E.164, consentement, idempotence, relances, Verify et callbacks.
Le SMS reste utile quand l’utilisateur n’a pas ouvert votre application. Il convient aux confirmations d’expédition, rappels de rendez-vous, alertes d’incident, vérifications de connexion et réponses de support qui doivent arriver vite.
Une intégration Twilio SMS paraît simple, mais le code de production ne se limite pas à client.messages.create. Il faut valider les numéros, conserver la preuve de consentement, éviter les doublons, relancer seulement les erreurs transitoires, recevoir les status callbacks, vérifier les signatures Webhook et journaliser sans exposer de données personnelles.
Ce guide montre comment demander à Claude Code une intégration exploitable avec Express + TypeScript. Il couvre l’envoi SMS, Twilio Verify, les callbacks de statut, l’idempotence, les relances, les logs, la sécurité et les pièges fréquents. Pour le reste du système, lisez aussi l’implémentation d’authentification, les Webhooks et la gestion des secrets.
Comprendre Twilio SMS simplement
Twilio fournit une API de communication. Votre backend demande à Twilio d’envoyer ce texte, depuis cet expéditeur, vers ce numéro. Twilio transmet le message au réseau opérateur et renvoie un Message SID, l’identifiant à garder pour le support, le suivi et les audits.
Les numéros doivent être en format E.164 : signe plus, indicatif pays, puis numéro, par exemple +15558675310 ou +819012345678. Ce n’est pas le format humain local, c’est le format stable pour l’API. La référence officielle est la page Twilio sur le format international des numéros.
La première réponse API ne prouve pas que le SMS est arrivé. Le statut peut évoluer de queued à sent, delivered, undelivered ou failed. Twilio peut appeler votre endpoint de status callback lors de ces changements. Gardez ouverts les docs Programmable Messaging, le tutoriel SMS avec Node.js, Messaging Webhooks et le guide outbound status callbacks.
Cas d’usage réalistes
Ne commencez pas par une fonction générique pour tout envoyer. Commencez par l’événement métier et ses règles d’échec.
| Cas | Pourquoi le SMS aide | Point de vigilance |
|---|---|---|
| Commande et expédition | Le client peut manquer l’email mais a besoin du statut | Mauvaise URL de suivi, doublons, désinscription |
| Rappel de rendez-vous | Réduit les absences et les confusions | Fuseau horaire, heure sensible, consentement |
| Alerte incident ou admin | Joint l’astreinte hors Slack ou email | Rafale d’alertes, rate limit, escalade |
| Connexion et 2FA | Protège le compte | Préférer Twilio Verify à un OTP maison |
| Accusé de réception support | Confirme que la demande est reçue | Ne pas mettre de données sensibles dans le corps |
Les prix, pays supportés, enregistrements d’expéditeur, règles de type A2P et obligations réglementaires changent. Ne les codez pas en dur. Vérifiez la console Twilio, les docs officielles actuelles et votre validation juridique ou conformité avant production.
Prompt pour Claude Code
Demandez le comportement de production, pas seulement l’appel 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
L’idempotence signifie qu’un même événement peut être rejoué sans produire un second effet externe. Pour le SMS, c’est indispensable : file de tâches, Webhook réémis, batch relancé ou action support peuvent tous répéter le même événement.
flowchart LR
A["Mise à jour commande"] --> B["Contrôle d'idempotence"]
B --> C["Twilio Messaging API"]
C --> D["Livraison SMS"]
C --> E["Stocker Message SID"]
D --> F["Status Callback"]
F --> G["Vérifier signature"]
G --> H["Mettre à jour logs"]
I["Vérification login"] --> J["Twilio Verify"]
Créer le projet minimal
Ce projet peut être copié et lancé. Sans vrais identifiants Twilio, l’envoi ne réussira pas, mais vous pouvez tester la validation d’environnement, les entrées, les doublons et la forme du callback local.
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 doit être une URL HTTPS accessible par Twilio. En local, utilisez ngrok ou Cloudflare Tunnel. La validation de signature dépend de l’URL exacte : protocole, proxy, query string et slash final comptent.
Implémenter SMS, idempotence et callbacks
Créez src/app.ts et collez ce code. Le demo utilise un Map en mémoire ; en production, remplacez-le par PostgreSQL, Redis, DynamoDB ou un autre stockage durable avec contrainte unique sur la clé d’idempotence.
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}`);
});
Lancez le serveur et envoyez une requête. La livraison réelle exige des identifiants Twilio valides, un expéditeur, un callback public et une destination autorisée par votre compte.
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 vous renvoyez le même eventId, l’API retourne l’état existant au lieu d’envoyer un second SMS. En production, stockez cet état dans une base durable.
Pour tester localement la forme du callback, définissez temporairement REQUIRE_TWILIO_SIGNATURE=false. En production, gardez 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"
Utiliser Twilio Verify pour l’OTP
Pour la connexion et la 2FA, ne commencez pas par générer vous-même six chiffres. L’OTP implique expiration, limites de renvoi, défense contre brute force, canaux, changements de numéro et audit. Twilio Verify et la Verification API sont conçus pour cela.
Ajoutez ce code avant app.listen dans 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 });
});
Après validation, mettez à jour votre propre table utilisateur, par exemple phoneVerifiedAt ou mfaEnabledAt. Pour le périmètre complet, associez cela au guide d’authentification et au guide Zod.
Consentement, conformité et sécurité
Le SMS arrive directement sur un numéro personnel. Enregistrez ce que l’utilisateur a accepté, où il l’a accepté, et comment les désinscriptions ou suppressions sont traitées. Les règles varient selon le pays, le type d’expéditeur et le contenu ; utilisez les docs Twilio actuelles et une revue conformité.
Ne mettez jamais un vrai Account SID, Auth Token, OTP, numéro complet ou corps complet dans le code, les prompts, captures ou logs. Excluez .env de Git et injectez les secrets via la plateforme ou un secret manager. Les logs devraient contenir surtout Message SID, event ID, type de message, numéro masqué, code d’erreur Twilio, tentatives et timestamp.
Pièges fréquents
Les erreurs courantes sont : envoyer un numéro local sans E.164, laisser une file de tâches dupliquer un SMS, exposer un callback sans signature, construire un OTP maison, et journaliser seulement “échec SMS” sans Message SID. Pour l’asynchrone, lisez les files de tâches ; pour la défense globale, lisez les bonnes pratiques sécurité.
Prompt de revue
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
Ce sujet est monétisable parce que le lecteur a souvent besoin de plus qu’une fonction : auth, files, Webhooks, logs, CLAUDE.md et revues. ClaudeCodeLab peut transformer cela en workflow réel via formation et conseil Claude Code.
Résumé
Twilio SMS commence par un petit appel API, mais la qualité de production dépend de E.164, du consentement, de l’idempotence, des relances, de la validation de callback et des logs respectueux de la vie privée. Donnez ces exigences à Claude Code dès le premier prompt.
Lors de la vérification pratique de cet article, le flux local a validé E.164, la gestion du eventId dupliqué, le parsing du Status Callback et les logs masqués. La livraison réelle dépend encore des identifiants Twilio, de l’expéditeur, du pays de destination et des règles actuelles de Twilio ; testez avec un numéro contrôlé et suivez le Message SID jusqu’au callback avant publication.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.