Twilio SMS mit Claude Code: Notifications, Verify und Webhooks produktionsreif
Baue Twilio SMS mit Claude Code: E.164, Consent, Idempotenz, Retries, Verify und Status Callbacks.
SMS ist weiterhin nützlich, wenn Nutzer die App nicht geöffnet haben. Versandmeldungen, Termin-Erinnerungen, Incident Alerts, Login-Verifikation und Support-Bestätigungen sind typische Fälle, in denen E-Mail oder Push allein nicht reichen.
Eine Twilio-SMS-Integration wirkt klein, aber Produktionscode besteht nicht nur aus client.messages.create. Du brauchst Telefonnummernvalidierung, Consent-Nachweis, Schutz vor doppeltem Versand, gezielte Retries, Status Callbacks, Webhook-Signaturprüfung und Logs ohne unnötige personenbezogene Daten.
Dieser Guide zeigt, wie du Claude Code eine brauchbare Express- und TypeScript-Integration erzeugen lässt. Enthalten sind ausgehende SMS, Twilio Verify, Status Callbacks, Idempotenz, Retry-Verhalten, Logging, Security und typische Fehler. Für angrenzende Themen siehe Authentication Implementation, Webhook Implementation und Secrets Management.
Twilio SMS einfach erklärt
Twilio stellt Kommunikationsfunktionen als API bereit. Dein Backend sendet eine Anfrage an Twilio: diesen Text, von diesem Absender, an diese Telefonnummer. Twilio übergibt die Nachricht an das Carrier-Netz und liefert eine Message SID zurück. Diese SID ist wichtig für Support, Tracking und Fehleranalyse.
Telefonnummern sollten im E.164-Format gesendet werden: Pluszeichen, Ländercode und Nummer, zum Beispiel +15558675310 oder +819012345678. Das ist nicht unbedingt die lokale Schreibweise für Menschen, sondern die API-sichere Form. Twilios Hinweise zur internationalen Nummernformatierung sind hier die Primärquelle.
Die erste API-Antwort bedeutet noch nicht, dass die SMS angekommen ist. Der Status kann später queued, sent, delivered, undelivered oder failed werden. Twilio kann diese Änderungen an deinen Status Callback senden. Prüfe dazu die offiziellen Seiten zu Programmable Messaging, SMS mit Node.js, Messaging Webhooks und Outbound Status Callbacks.
Realistische Use Cases
Starte nicht mit einem generischen SMS-Helper. Definiere zuerst das Business Event und die Fehlerrisiken.
| Use Case | Warum SMS hilft | Worauf achten |
|---|---|---|
| Bestellung und Versand | Kunden verpassen E-Mail, brauchen aber Status | Falsche Tracking-URL, Doppelversand, Opt-out |
| Terminerinnerung | Reduziert No-shows und Verwirrung | Zeitzone, Ruhezeiten, Consent |
| Incident oder Admin Alert | Erreicht Bereitschaft auch ohne Slack/E-Mail | Alert-Flut, Rate Limits, Eskalation |
| Login und 2FA | Schützt Accounts | Twilio Verify vor eigenem OTP prüfen |
| Support-Bestätigung | Nutzer wissen, dass die Anfrage ankam | Keine sensiblen Details im Text |
Preise, unterstützte Länder, Sender-Registrierung, A2P-ähnliche Regeln und Compliance-Anforderungen ändern sich. Dieser Artikel schreibt solche Fakten nicht fest. Prüfe vor Launch die aktuelle Twilio Console, offizielle Docs und die zuständige Rechts- oder Compliance-Prüfung.
Prompt für Claude Code
Fordere Produktionsverhalten an, nicht nur einen 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
Idempotenz bedeutet, dass dasselbe Event erneut verarbeitet werden kann, ohne eine zweite externe Wirkung auszulösen. Bei SMS ist das zentral, weil Queue-Retries, Webhook-Redelivery, Batch-Replays und manuelle Support-Aktionen denselben Versand mehrfach anstoßen können.
flowchart LR
A["Order update"] --> B["Idempotenzcheck"]
B --> C["Twilio Messaging API"]
C --> D["SMS-Zustellung"]
C --> E["Message SID speichern"]
D --> F["Status Callback"]
F --> G["Signatur prüfen"]
G --> H["Delivery Log aktualisieren"]
I["Login Check"] --> J["Twilio Verify"]
Minimalprojekt erstellen
Dieses Projekt lässt sich kopieren und starten. Ohne echte Twilio-Zugangsdaten schlägt der Versand fehl, aber Environment-Parsing, Request-Validierung, Duplikatbehandlung und lokales Callback-Parsing lassen sich testen.
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 muss eine HTTPS-URL sein, die Twilio erreichen kann. Lokal eignen sich ngrok oder Cloudflare Tunnel. Signaturprüfung hängt an der exakten URL, also prüfe Protokoll, Proxy, Query String und Slash am Ende.
SMS, Idempotenz und Callbacks implementieren
Erstelle src/app.ts und füge diesen Code ein. Das Demo nutzt eine In-Memory-Map; in Produktion brauchst du PostgreSQL, Redis, DynamoDB oder einen anderen dauerhaften Store mit Unique Constraint auf dem Idempotenzschlüssel.
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}`);
});
Starte den Server und sende einen Request. Für echte Zustellung brauchst du gültige Twilio-Credentials, einen Absender, eine öffentliche Callback-URL und ein Ziel, das dein Account senden darf.
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"
}'
Mit derselben eventId sendet die API keine zweite SMS, sondern gibt den vorhandenen Status zurück. In Produktion muss dieser Status dauerhaft gespeichert werden.
Für einen lokalen Callback-Test kannst du kurz REQUIRE_TWILIO_SIGNATURE=false setzen. In Produktion bleibt es 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"
Twilio Verify für OTP nutzen
Für Login und 2FA solltest du nicht mit sechs eigenen Zufallsziffern starten. OTP braucht Ablaufzeiten, Resend Limits, Schutz gegen Brute Force, Kanalregeln, Nummernwechsel und Audit Logs. Twilio Verify und die Verification API sind dafür vorgesehen.
Füge diesen Code vor app.listen in src/app.ts ein.
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 });
});
Nach erfolgreicher Verify-Prüfung aktualisierst du deine eigene User-Tabelle, etwa phoneVerifiedAt oder mfaEnabledAt. Für den gesamten Auth-Bereich helfen der Authentication Guide und der Zod Validation Guide.
Consent, Compliance und Security
SMS geht direkt an eine persönliche Telefonnummer. Speichere, welchem Zweck der Nutzer zugestimmt hat, wo diese Zustimmung erfolgte und wie Opt-out oder Suppression verarbeitet werden. Anforderungen variieren nach Land, Sender-Typ und Inhalt; nutze aktuelle Twilio-Dokumentation und rechtliche Prüfung.
Echte Account SID, Auth Token, OTP, vollständige Telefonnummern und komplette Nachrichten gehören nicht in Code, Prompts, Screenshots oder Logs. .env bleibt aus Git heraus, Produktion bekommt Secrets über Plattform oder Secret Manager. Logs brauchen meist Message SID, Event ID, Nachrichtentyp, maskierte Nummer, Twilio Error Code, Versuchszahl und Timestamp.
Häufige Fehler
Typische Fehler sind lokale Nummern ohne E.164, Queue-Retries ohne Idempotenz, öffentliche Callbacks ohne Signaturprüfung, selbst gebaute OTP-Flows und Logs ohne Message SID. Für asynchrone Verarbeitung siehe Queue Systems, für Abwehrmaßnahmen Security Best Practices.
Review-Prompt
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
Dieses Thema eignet sich für monetarisierbaren Content, weil Leser meist mehr brauchen als eine Funktion: Auth, Queues, Webhooks, Logs, CLAUDE.md und Review Gates. ClaudeCodeLab kann das über Claude Code Training und Beratung in einen repository-spezifischen Workflow übersetzen.
Fazit
Twilio SMS beginnt mit einem kurzen API Call, aber Produktionsqualität hängt an E.164, Consent, Idempotenz, Retries, Callback-Signatur und datensparsamen Logs. Gib Claude Code diese Anforderungen direkt im ersten Prompt und prüfe das Ergebnis als operative Integration.
Im praktischen Check zu diesem Artikel wurden lokale E.164-Validierung, doppeltes eventId-Handling, Status-Callback-Parsing und maskierte Logs geprüft. Echte SMS-Zustellung hängt weiter von Twilio-Credentials, Sender-Setup, Zielland und aktuellen Twilio-Regeln ab. Vor Launch mit einer kontrollierten Testnummer Message SID und Callback-Status nachverfolgen.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.