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,再变成 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 与回调状态。

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

免费 PDF: Claude Code 速查表

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

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

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

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