用 Claude Code 集成 Twilio SMS:通知、Verify 与 Webhook 实战
用Claude Code实现Twilio SMS通知:E.164、同意、幂等、重试、Verify和状态回调。
SMS 是少数能在用户没有打开应用时仍然触达的通知渠道。发货提醒、预约提醒、故障告警、登录验证、客服受理通知,都很适合用短信补上邮件和站内通知的空白。
但 Twilio SMS 集成不能只停留在“调用一次 API”。真正上线时,你需要处理电话号码格式、用户同意、重复发送、失败重试、状态回调、Webhook 签名验证,以及日志中的个人信息。薄示例能跑通,不代表能安全运营。
这篇文章用 Express + TypeScript 展示如何让 Claude Code 生成可维护的 Twilio SMS 集成:出站短信、Twilio Verify、状态回调、幂等性、重试、日志、同意与合规检查。相关基础可以继续看认证实现、Webhook 实现和Secrets Management。
先用普通话理解 Twilio SMS
Twilio 提供的是通信 API。你的应用向 Twilio 发送请求,说明要从哪个 Twilio 号码、向哪个用户号码、发送哪段文本。Twilio 再把消息交给运营商网络,并返回一个 Message SID。这个 SID 是排查失败、查询状态、和客服对账时最重要的线索。
电话号码应使用 E.164 格式,也就是加号、国家码、号码主体,例如 +15558675310 或 +819012345678。它不是给用户看的本地格式,而是 API 能稳定识别的国际格式。Twilio 的国际号码格式说明是这部分的官方参考。
发送 API 返回成功后,短信还没有真正结束。消息可能先是 queued,再变成 sent、delivered、undelivered 或 failed。你可以通过 Status Callback 接收这些变化。实现时请同时参考 Twilio 的Programmable Messaging、Node.js 发送短信教程、Messaging Webhooks和状态回调指南。
真实业务场景
不要一开始就写“万能短信函数”。先定义业务事件和失败规则。
| 场景 | 为什么适合 SMS | 重点风险 |
|---|---|---|
| 订单和发货通知 | 用户可能不看邮件,但需要知道订单状态 | 追踪链接错误、重复发送、退订处理 |
| 预约提醒 | 减少爽约和临时沟通成本 | 时区、夜间发送、同意记录 |
| 故障和管理员告警 | 值班人员可能没看 Slack 或邮件 | 告警风暴、限流、升级策略 |
| 登录验证和 2FA | 提升账号安全 | 优先考虑 Twilio Verify,不要轻易自研 OTP |
| 客服受理确认 | 让用户知道请求已经收到 | 正文里不要放过多敏感信息 |
价格、可发送国家、发送者注册、A2P 类规则和合规要求都会变化。不要在代码或文章里写死这些结论。上线前请检查 Twilio Console、最新官方文档,并让法务或合规负责人确认。
给 Claude Code 的提示词
提示词要覆盖运营要求,而不是只说“帮我发短信”。
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
幂等性指同一个业务事件被重复执行时,结果仍然安全。短信一旦发出就无法撤回,所以订单事件、队列重试、Webhook 重放、客服手动重试都必须先经过幂等检查。
flowchart LR
A["订单状态更新"] --> B["幂等检查"]
B --> C["Twilio Messaging API"]
C --> D["短信投递"]
C --> E["保存 Message SID"]
D --> F["Status Callback"]
F --> G["签名验证"]
G --> H["更新投递日志"]
I["登录验证"] --> J["Twilio Verify"]
创建最小项目
下面的项目可以直接复制运行。没有真实 Twilio 凭证时,短信不会真正发出,但你仍可检查环境变量解析、输入校验、重复事件处理和本地回调解析。
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 必须是 Twilio 能访问的 HTTPS 地址。本地开发可以使用 ngrok 或 Cloudflare Tunnel。Twilio 签名验证依赖完整 URL,协议、代理、查询参数和尾部斜杠不一致都会导致验证失败。
实现短信、幂等和状态回调
创建 src/app.ts 并粘贴以下代码。示例使用内存 Map 保存状态;生产环境请换成 PostgreSQL、Redis、DynamoDB 等持久化存储,并给幂等键加唯一约束。
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}`);
});
运行服务并发送测试请求。真实投递需要有效的 Twilio 凭证、发送者号码、可访问的回调地址,以及账号允许发送的目标号码。
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"
}'
再次使用相同 eventId 调用时,API 会返回已有状态,而不是再次发送短信。生产环境要把这个状态放进持久化数据库。
本地只想检查 Status Callback 形状时,可以临时设置 REQUIRE_TWILIO_SIGNATURE=false。不要在生产环境关闭签名验证。
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 处理 OTP
登录验证和二次验证不要从“生成 6 位数字”开始。OTP 还包含过期时间、重发限制、暴力尝试防护、渠道策略和审计日志。Twilio Verify与Verification API就是为这类场景准备的。
把下面代码加到 src/app.ts 的 app.listen 前面。
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 });
});
Verify 通过后,再更新你自己的用户表,例如 phoneVerifiedAt 或 mfaEnabledAt。完整认证边界可以配合认证实现指南和Zod 验证指南设计。
同意、合规与安全
SMS 会直接到达个人电话号码,因此要记录用户同意了什么、在哪里同意、如何退订或抑制发送。不同国家、发送者类型和消息内容的要求不同,不能把某篇文章里的固定规则当成永久事实。
不要把真实的 Account SID、Auth Token、OTP、完整电话号码或短信正文放进代码、提示词、截图或日志。.env 不进 Git,生产环境从托管平台或密钥管理系统注入。Claude Code 提示词里也要明确要求示例值必须是占位符。
日志通常只需要 Message SID、事件 ID、消息类型、脱敏号码、Twilio 错误码、重试次数和时间戳。如果业务必须保留正文,先定义保留期限、访问权限和删除方式。
常见失败
常见错误包括:把本地号码直接发给 API;队列重试导致同一短信重复发送;Status Callback 暴露在公网却没有签名验证;自己实现 OTP;以及日志只写“发送失败”,没有 Message SID 和错误码。队列和重试的设计可以继续参考队列系统指南,安全审查可以参考安全最佳实践。
发布前让 Claude Code 再审一遍
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
这类集成很适合作为商业化文章,因为读者通常不是只要一段代码,而是需要认证、队列、Webhook、日志和团队审查流程。ClaudeCodeLab 可以通过Claude Code 培训与咨询把这些规则落到真实仓库中。
总结
Twilio SMS 的起点很短,但上线质量取决于 E.164、同意记录、幂等、重试、回调签名和隐私友好的日志。让 Claude Code 从第一条提示词就处理这些运营要求,比后期补救更可靠。
本文的动手检查确认了本地 E.164 校验、重复 eventId 防重、Status Callback 解析和脱敏日志形状。真实短信投递仍取决于 Twilio 凭证、发送者配置、目标国家设置和当前 Twilio 规则,上线前请用受控测试号码追踪 Message SID 与回调状态。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。