Use Cases (更新: 2026/6/2)

用 Claude Code 集成 Twilio SMS:通知、Verify 与 Webhook 实战

用Claude Code实现Twilio SMS通知:E.164、同意、幂等、重试、Verify和状态回调。

用 Claude Code 集成 Twilio SMS:通知、Verify 与 Webhook 实战

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,再变成 sentdeliveredundeliveredfailed。你可以通过 Status Callback 接收这些变化。实现时请同时参考 Twilio 的Programmable MessagingNode.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 VerifyVerification API就是为这类场景准备的。

把下面代码加到 src/app.tsapp.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 通过后,再更新你自己的用户表,例如 phoneVerifiedAtmfaEnabledAt。完整认证边界可以配合认证实现指南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 与回调状态。

#Claude Code #Twilio #SMS #通知 #API集成
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。