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

Claude CodeでTwilio SMS連携を本番実装する方法:通知・Verify・Webhookまで

Claude CodeでTwilio SMS通知を実装。E.164、同意管理、冪等性、リトライ、Webhookまで実例で解説。

Claude CodeでTwilio SMS連携を本番実装する方法:通知・Verify・Webhookまで

SMS通知は、アプリを開いていないユーザーにも届く強い連絡手段です。注文発送、予約リマインド、障害アラート、ログイン確認など、メールやアプリ通知だけでは間に合わない場面で役に立ちます。

一方で、TwilioのSMS実装は「APIを1回呼ぶだけ」に見えて、本番では失敗しやすい領域です。電話番号の形式、ユーザー同意、配信失敗、重複送信、Webhook署名、ログに残る個人情報まで考えないと、動くサンプルがそのまま事故の入口になります。

この記事では、Claude CodeにTwilio SMS連携を頼むときの設計、Express + TypeScriptでコピペして動かせる実装、Twilio Verify、Status Callback、冪等性、リトライ、ログ設計までを1本の実務フローとして整理します。認証全体はClaude Codeで認証機能を実装する方法、Webhookの基礎はClaude CodeでWebhook実装、秘密情報の扱いはSecrets Managementも合わせて確認してください。

Twilio SMS連携を普通の言葉で理解する

Twilioは、SMSや音声通話などの通信機能をAPIとして使えるサービスです。あなたのアプリはTwilioのREST APIへ「この番号に、この送信元から、この本文を送ってください」とリクエストします。Twilioはその後、通信キャリアへ配送し、送信結果をMessage SIDというIDで返します。

SMSを送るときに最低限必要なのは、送信先番号、送信元番号、本文、Twilioの認証情報です。送信先と送信元の電話番号は、国番号から始まるE.164形式、たとえば日本なら+819012345678のような形にそろえます。E.164は「国際的に一意に読める電話番号の書き方」と考えると十分です。詳細はTwilioの国際電話番号フォーマット案内を確認してください。

送った後の結果は、APIレスポンスだけで完結しません。SMSは通信キャリアを通るため、queuedsentdeliveredundeliveredfailedのように状態が後から変わります。その変化を受け取る仕組みがStatus Callbackです。TwilioのProgrammable MessagingNode.jsでSMSを送るチュートリアルMessaging WebhooksStatus Callbackの説明を一次情報として開きながら実装してください。

実務で使いやすいユースケース

最初から「SMSを何でも送れる共通関数」を作るより、用途ごとの失敗条件を先に決める方が安全です。

ユースケースSMSで送る理由注意点
ECの注文・発送通知メールを見ない顧客にも重要な状態変更を伝えられる追跡URLの誤送信、重複送信、配信停止希望
予約リマインド来店忘れや当日キャンセルを減らせる時刻、タイムゾーン、深夜送信、同意管理
障害・管理者アラートSlackやメールを見ていない担当者にも届く連続障害で大量送信しないレート制限
ログイン確認・2FAアカウント保護に使えるOTPを自作せずTwilio Verifyを検討
問い合わせの一次返信「受け付けた」ことを即時に返せる個人情報を本文に入れすぎない

料金、送信可能な国、送信者登録、A2Pやブランド登録のような制度は変わります。この記事では固定の料金や規制要件を断言しません。公開前には必ずTwilio Consoleと最新の公式ドキュメント、必要に応じて法務・コンプライアンス担当の確認を通してください。

Claude Codeに渡す設計プロンプト

Claude Codeへは「SMS送信コードを書いて」ではなく、本番で壊れやすい条件を含めて依頼します。

Express + TypeScriptでTwilio SMS通知を実装してください。

要件:
- Twilio認証情報、送信元番号、Verify Service SIDは環境変数から読む
- 電話番号はE.164形式でZodバリデーションする
- 注文発送SMSのAPIをPOST /api/order-shipped-smsとして作る
- eventIdを冪等性キーにして同じイベントを二重送信しない
- 送信失敗は429または5xx系だけ指数的にリトライする
- ログには電話番号全文や本文全文を出さない
- Status CallbackをPOST /twilio/status-callbackで受ける
- Twilio署名検証を本番で必須にする
- Twilio Verifyのstart/check APIも追加する
- .env.example、package.json、起動手順、curl例を付ける

このプロンプトでは、Claude Codeに「API呼び出し」ではなく「運用できるSMS連携」を作らせています。特に冪等性は重要です。決済Webhook、注文更新、バッチ再実行、キューのリトライが絡むと、同じイベントが複数回届くことがあります。SMSは一度送ると取り消せないため、重複送信の予防を最初に入れます。

flowchart LR
  A["アプリの注文更新"] --> B["冪等性チェック"]
  B --> C["Twilio Messaging API"]
  C --> D["SMS配送"]
  C --> E["Message SIDを保存"]
  D --> F["Status Callback"]
  F --> G["署名検証"]
  G --> H["配信ログ更新"]
  I["ログイン確認"] --> J["Twilio Verify"]

最小プロジェクトを作る

次の例は、Express + TypeScriptの小さなAPIとしてそのまま試せる形です。Twilioの本物の認証情報がない状態ではSMS送信自体は成功しませんが、環境変数チェック、入力バリデーション、冪等性、ローカルのStatus Callback受信は確認できます。

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 URLにします。ローカル検証ではngrokやCloudflare Tunnelなどを使います。Status Callbackの署名検証はURLが完全一致しないと失敗するため、プロキシや末尾スラッシュの違いにも注意してください。

SMS送信、冪等性、Status Callbackを実装する

src/app.tsを作り、次のコードを貼り付けます。デモでは送信履歴をメモリに保存しています。本番ではPostgreSQL、Redis、DynamoDBなどに置き換え、eventIdへユニーク制約を置くのが現実的です。

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}`);
});

起動後、次のリクエストで注文発送SMSを送ります。実送信にはTwilio Consoleで取得した認証情報、送信元番号、送信可能な宛先番号が必要です。

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は既存の送信結果を返し、SMSを二重送信しません。メモリ保存なのでプロセス再起動で消えますが、本番DBに置き換えると同じ考え方で運用できます。

ローカルだけでStatus Callbackの形を確認したい場合は、.envREQUIRE_TWILIO_SIGNATURE=falseにしてから次を実行します。本番では必ず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でOTPを自作しない

ログイン確認や二要素認証では、認証コードの生成、期限、再送制限、試行回数、ロック、チャンネル切り替えを自作しがちです。初学者ほど「6桁の乱数を送ればよい」と考えますが、実務では攻撃や誤用に弱くなります。TwilioにはVerifyVerification APIがあるため、OTP用途はまずVerifyを検討してください。

同じsrc/app.tsへ次のコードをapp.listenより前に追加すると、SMSで認証コードを開始し、ユーザー入力コードを確認できます。

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成功後に自社DBのphoneVerifiedAtmfaEnabledAtを更新します。認証状態、セッション、RBACまで含めるなら認証実装ガイドZodバリデーションを合わせて設計すると、フォーム、API、環境変数の境界が見えやすくなります。

同意、コンプライアンス、セキュリティ

SMSはユーザーの電話番号へ直接届くため、メールよりも慎重に扱います。送信前に、どの目的でSMSを送るのか、ユーザーがどこで同意したのか、配信停止をどう処理するのかを記録してください。国や地域、送信内容、送信元種別によって要件は変わるため、この記事では固定ルールを断言しません。Twilioの最新ガイドと法務確認を基準にします。

セキュリティ面では、TwilioのAccount SIDAuth Tokenをコードや記事に貼り付けないことが最初のルールです。.envはGit管理から外し、CIや本番環境ではシークレットストアから注入します。Claude Codeに依頼するときも「実値を出力しない」「ログにAuth Tokenを出さない」「サンプルはプレースホルダーにする」と明示してください。秘密情報の運用はClaude CodeでSecrets Managementを実装するが参考になります。

ログには電話番号全文、SMS本文全文、OTPコードを残さないでください。障害対応に必要なのは、多くの場合、Message SID、イベントID、送信種別、マスク済み電話番号、Twilioのエラーコード、試行回数、タイムスタンプです。本文を残す必要がある場合でも、保持期間、閲覧権限、削除方法を決めてからにします。

よくある失敗例と落とし穴

1つ目は、国内表記の電話番号をそのままAPIへ渡すことです。090-1234-5678のような文字列は人間には読めますが、APIでは国番号が分かりません。保存時か送信前にE.164形式へ正規化し、ユーザー入力欄には例を出します。

2つ目は、キューのリトライで同じSMSを何度も送ることです。SMSは決済やメールよりも「二重に届いた」ことが目立ちます。注文ID、イベントID、送信種別を組み合わせた冪等性キーをDBに保存し、ユニーク制約で守ります。キューシステムの記事の考え方をSMSにも適用してください。

3つ目は、Status Callbackを受けているのに署名検証をしていないことです。Webhook URLが外部公開されるなら、第三者が偽の配信結果をPOSTできます。Twilio署名の検証、正しい公開URL、HTTPS、失敗時の403応答を確認します。

4つ目は、OTPを自作してしまうことです。コードの有効期限、再送、総当たり対策、電話番号変更、監査ログまで作ると、見た目以上に重い実装になります。特別な理由がなければTwilio Verifyを使い、アプリ側は「検証済み」という状態管理に集中します。

5つ目は、障害時のログが足りないことです。送信に失敗しましただけでは調査できません。Message SID、Twilioのエラーコード、送信元、送信先の国、リトライ回数、Status Callbackの到着有無を構造化ログで残します。ただし、個人情報はマスクします。

Claude Codeにレビューさせる観点

実装後は、Claude Codeにもう一度レビュー役をさせます。最初の実装担当と同じ会話でも構いませんが、依頼文は「改善して」ではなく、観点を固定します。

このTwilio SMS実装を本番前レビューしてください。

観点:
- E.164バリデーションが送信前に必ず通るか
- 同意時刻と送信目的がAPI入力かDBで追跡できるか
- eventIdによる冪等性が並列リクエストでも破れないか
- 429/5xxだけをリトライし、4xxを無駄に再試行していないか
- Status CallbackのTwilio署名検証が本番で必須か
- Auth Token、OTP、電話番号全文、SMS本文全文がログに出ないか
- 料金、送信国、規制要件をコード内コメントで断言していないか
- 失敗時にサポート担当がMessage SIDで追跡できるか

このレビュー観点は、広告収益や相談導線を持つサイトでも役に立ちます。SMS通知は読者の実務に近いテーマなので、薄いサンプルだけでは信頼につながりません。ClaudeCodeLabでは、こうしたAPI連携、CLAUDE.md、セキュリティレビュー、Webhook、運用ログ設計を、実リポジトリ前提でClaude Code研修・導入相談として整理できます。

まとめ

Twilio SMS連携は、短いコードで始められる反面、本番では電話番号形式、同意、配信失敗、冪等性、Webhook署名、ログの個人情報管理が品質を決めます。Claude Codeを使うなら、最初のプロンプトへ運用要件を入れ、SMS送信、Status Callback、Verify、DBのユニーク制約、構造化ログまで一続きで作らせるのが現実的です。

この記事で紹介した内容を検証した結果、ローカルではZodのE.164チェック、eventId重複時の二重送信防止、Status Callbackの入力検証、マスク済みログの形を確認できました。実際のSMS配送はTwilioの認証情報、送信元番号、宛先国の設定、現在のTwilioルールに依存するため、本番前に小さなテスト番号でMessage SIDとStatus Callbackまで追跡してから公開してください。

#Claude Code #Twilio #SMS #通知 #API連携
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。