Twilio SMSをNodeで送る・受ける・OTPに使う実装手順(日本の国際送信込み)
TwilioのSMSをNode/Expressで実装。番号取得、送信コード、Webhook受信、OTP認証、料金と到達、日本からの国際送信の注意までコピペで動く例で解説。
初めてTwilioでSMSを送ったとき、僕は「成功」のレスポンスを見て小さくガッツポーズしました。status: queued と返ってきたからです。
でも、スマホには何も届きませんでした。30分待っても来ない。
調べてわかったのは、queued は「Twilioが受け取った」だけで、「相手に届いた」ではないということ。SMSはこの後にキャリアを通って、sent → delivered、あるいは静かに undelivered へ変わります。APIが200を返した瞬間に安心すると、ここで足をすくわれます。
SMSは「APIを1回叩けば終わり」に見えて、実際は番号の形式、Webhookでの受信、二重送信、料金、そして日本からの国際送信という地雷が並んでいます。この記事では、番号の取得から送信・受信・OTP(ワンタイムパスワード)まで、僕が踏んだ落とし穴ごと順番に書きます。コードはNode + Expressでそのまま動く形にしました。
この記事の要点
- SMS送信は
status: queuedで安心しない。本当の結果は Webhook(Status Callback) で後から届く。 - 電話番号は E.164形式(
+819012345678)に正規化してから渡す。090-...のままはNG。 - OTPは自作せず Twilio Verify を使う。期限・再送制限・総当たり対策を肩代わりしてくれる。
- 同じ通知を二度送らないよう 冪等性キー を必ず持つ。SMSは「二重に届いた」が目立つ。
- 日本宛は 国際A2P SMS 扱い。送信元の表示や到達は国内携帯番号とは別物で、事前確認が要る。
Twilioで何が起きているのか、まず整理する
Twilioは、SMSや電話をAPIから使えるようにするサービスです。あなたのコードはTwilioへ「この番号に、この送信元から、この文面を送って」と頼みます。Twilioがキャリアへ橋渡しし、Message SID という受付番号を返します。
ここで大事なのは、返ってくる結果が 二段構え だということ。
- API応答: Twilioが依頼を受け付けたか(
queued/accepted)。ここでは到達はまだわからない。 - Status Callback: 実際の配送結果(
sent/delivered/undelivered/failed)が、後からWebhookであなたのサーバーに飛んでくる。
冒頭で僕がハマったのは、1だけ見て満足したからです。本当に届いたかを知りたければ、2を受ける口を用意しないといけません。一次情報はTwilioのProgrammable Messaging公式ドキュメントとNode.jsでSMSを送るチュートリアルを開きながら進めるのが確実です。
電話番号を取得して送信元を決める
コードの前に、送信元になる電話番号が要ります。Twilio Consoleの「Phone Numbers」から、SMS対応の番号を1つ買います(テスト用なら数百円/月から)。買った番号が、あなたのSMSの「差出人」になります。
番号には、扱える機能の違いがあります。
| 送信元の種類 | 主な用途 | ざっくりした特徴 |
|---|---|---|
| ローカル番号(米国 +1 など) | 双方向のSMS、受信もしたい場合 | 受信Webhookを設定できる。国によって登録が要る |
| Toll-Free番号(米国 0120 相当) | 通知中心、ある程度の量 | 米国向けに使いやすい。事前検証が必要 |
| Alphanumeric Sender ID | 海外宛の片方向通知(差出人名を文字で表示) | 返信不可。日本宛など国際送信で使う場面が多い |
ここで日本の事情を先に言っておきます。日本国内の携帯番号を送信元にしたSMS送信は、Twilioでは基本的に提供されていません。 日本のユーザーへ送る場合は、海外番号やAlphanumeric Sender IDからの 国際A2P SMS になります。差出人が番号ではなく英数字名で表示されたり、キャリア側のフィルタで届きにくかったりするので、本番前に自分の端末で必ず1通テストしてください。
用途ごとに「失敗していい条件」を先に決める
「何でも送れる共通関数」を最初に作ると、たいてい事故ります。用途ごとに、何を守るかが違うからです。
| 用途 | SMSを選ぶ理由 | 先に決めること |
|---|---|---|
| 注文・発送通知 | メールを見ない人にも状態変更が届く | 二重送信の防止、追跡URLの取り違え |
| 予約リマインド | 当日キャンセルを減らせる | 送信時刻・タイムゾーン・深夜を避ける |
| 障害アラート | Slackを見ていない担当にも届く | 連続障害での大量送信を止めるレート制限 |
| ログイン・2要素認証 | アカウント保護に効く | OTPを自作せずVerifyに寄せる |
| 受信(ユーザーからの返信) | 解約・問い合わせを拾える | Webhookの署名検証、STOP対応 |
料金、送信できる国、送信者登録(A2Pやブランド登録)といった制度はよく変わります。この記事では固定の金額や規制を断言しません。公開前にTwilio Consoleと最新の公式ドキュメント、必要なら法務の確認を通してください。
最小プロジェクトを作る
ここから手を動かします。Express + TypeScriptの小さなAPIです。本物の認証情報がなくても、環境変数チェック・入力バリデーション・冪等性・ローカルでのWebhook受信までは確認できます。
mkdir twilio-sms-demo
cd twilio-sms-demo
npm init -y
npm install express twilio dotenv zod
npm install -D typescript tsx @types/express
package.json に起動スクリプトだけ足します。
{
"type": "module",
"scripts": {
"dev": "tsx src/app.ts"
}
}
TypeScriptの設定です。
{
"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でトンネルを張ります。署名検証はURLが1文字でも違うと失敗するので、末尾スラッシュやプロキシの差に注意してください。
Account SID と Auth Token をコードに直書きしないのが最初のルールです。.env はGit管理から外し、CIや本番ではシークレットストアから注入します。この扱いはAPIキーを漏らさない秘密情報管理の手順にまとめてあります(僕がキーを漏らして30分で悪用された話です)。
SMSを送る・受け取る・結果を追う
src/app.ts を作って、次を貼り付けます。これがこの記事のコピペで動く本体です。送信・冪等性・Status Callback(送信結果の受信)・ユーザーからの着信SMS受信まで、ひと続きで入っています。デモは履歴をメモリに置いていますが、本番はDBに替えて eventId にユニーク制約を張ってください。
import "dotenv/config";
import express from "express";
import twilio from "twilio";
import { z } from "zod";
// 電話番号はE.164形式(+ と国番号始まり)だけ通す
const e164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, {
message: "E.164形式で。例: +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;
};
// 本番はDBへ。ここではメモリで冪等性を確認する
const deliveries = new Map<string, Delivery>();
const sendSmsSchema = z.object({
eventId: z.string().min(6).max(120), // 同じ通知を一意に識別する鍵
phone: e164Schema,
message: z.string().min(1).max(480),
});
// 電話番号は末尾4桁だけ残してマスク
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);
}
// 429(混雑)と5xx(サーバー側)だけ再送する。4xxは投げ返す
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, // 結果をWebhookで受け取る
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("再送ループが想定外に終了しました");
}
// TwilioからのWebhookが本物か署名で確かめる
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());
// 1) SMSを送る(冪等性つき)
app.post("/api/send-sms", async (req, res) => {
const parsed = sendSmsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() });
}
const input = parsed.data;
const key = `send:${input.eventId}`;
const existing = deliveries.get(key);
// 同じeventIdなら送らずに前回の結果を返す
if (existing?.status === "sent") {
return res.status(200).json({ duplicate: true, sid: existing.sid });
}
if (existing?.status === "pending") {
return res.status(202).json({ duplicate: true, status: existing.status });
}
deliveries.set(key, { attempts: 0, status: "pending", updatedAt: new Date().toISOString() });
const statusCallback = new URL("/twilio/status-callback", env.PUBLIC_BASE_URL).toString();
try {
const result = await sendSmsWithRetry({ body: input.message, statusCallback, to: input.phone });
deliveries.set(key, {
attempts: result.attempts,
sid: result.sid,
status: "sent",
updatedAt: new Date().toISOString(),
});
console.log("sms_sent", { key, sid: result.sid, to: maskPhone(input.phone) });
return res.status(202).json({ accepted: true, sid: result.sid });
} catch (error) {
deliveries.set(key, {
attempts: 3,
error: getErrorMessage(error),
status: "failed",
updatedAt: new Date().toISOString(),
});
console.error("sms_failed", {
key,
message: getErrorMessage(error),
status: getErrorStatus(error),
to: maskPhone(input.phone),
});
return res.status(502).json({ error: "sms_delivery_failed" });
}
});
// 2) 送信結果を受け取る(delivered/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");
}
console.log("twilio_status", {
sid: req.body.MessageSid,
status: req.body.MessageStatus,
errorCode: req.body.ErrorCode,
to: req.body.To ? maskPhone(req.body.To) : undefined,
});
return res.status(204).send();
});
// 3) ユーザーからの着信SMSを受け取って自動返信する
app.post("/twilio/inbound", express.urlencoded({ extended: false }), (req, res) => {
if (env.REQUIRE_TWILIO_SIGNATURE === "true" && !verifyTwilioSignature(req)) {
return res.status(403).send("invalid signature");
}
const from = req.body.From ?? "";
const text = (req.body.Body ?? "").trim().toUpperCase();
console.log("sms_inbound", { from: maskPhone(from), body: text });
// TwiMLで返信。STOPなら配信停止を受け付ける
const twiml = new twilio.twiml.MessagingResponse();
if (text === "STOP") {
twiml.message("配信を停止しました。再開するにはSTARTと返信してください。");
} else {
twiml.message("メッセージを受け取りました。担当者が確認します。");
}
res.type("text/xml").send(twiml.toString());
});
app.listen(env.PORT, () => {
console.log(`Twilio SMS demo: http://localhost:${env.PORT}`);
});
起動して、送信を叩いてみます。実際に届かせるにはTwilio Consoleで取った認証情報・送信元番号・到達可能な宛先が要ります。
npm run dev
curl -X POST http://localhost:3000/api/send-sms \
-H "Content-Type: application/json" \
-d '{
"eventId": "order_1001_shipped_v1",
"phone": "+15558675310",
"message": "ご注文1001を発送しました。"
}'
同じ eventId でもう一度叩くと、APIは前回の結果を返してSMSを二度送りません。メモリ保存なので再起動で消えますが、DBに替えれば同じ考え方で運用できます。
送信結果のWebhook(Status Callback)をローカルで形だけ確認したいときは、.env で REQUIRE_TWILIO_SIGNATURE=false にしてから叩きます。本番では必ず true に戻してください。 外部公開URLに署名検証がないと、第三者が偽の配送結果や着信を投げ込めます。
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"
着信SMSを受けるには、Twilio Consoleで購入した番号の「A MESSAGE COMES IN」に https://あなたの公開URL/twilio/inbound を設定します。これでユーザーが番号へ返信すると /twilio/inbound が呼ばれ、上のコードがTwiMLで自動返信します。Webhookの考え方の土台はWebhook実装の基礎も合わせてどうぞ。
OTPは自作しない。Twilio Verifyに寄せる
ログイン確認や2要素認証で「6桁の乱数を送ればいいだろう」と考えがちですが、これは沼です。コードの有効期限、再送のクールダウン、試行回数の上限、総当たり対策、電話番号変更、監査ログ——全部自分で面倒を見ることになります。
TwilioにはVerifyがあり、この面倒を肩代わりしてくれます。OTP用途はまずこちらを検討してください。同じ src/app.ts の app.listen より前に足します。
const verifyStartSchema = z.object({ phone: e164Schema });
const verifyCheckSchema = z.object({ phone: e164Schema, code: z.string().min(4).max(10) });
function requireVerifyServiceSid() {
if (!env.TWILIO_VERIFY_SERVICE_SID) {
throw new Error("Verifyには TWILIO_VERIFY_SERVICE_SID が必要です");
}
return env.TWILIO_VERIFY_SERVICE_SID;
}
// コードを送る(生成も再送制限もTwilio側がやる)
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({ to: parsed.data.phone, code: parsed.data.code });
return res.json({ approved: check.status === "approved", status: check.status });
});
アプリ側がやるのは「検証済みかどうか」の状態管理だけになります。成功したら自社DBの phoneVerifiedAt を更新する、といった具合です。SMS OTPを認証フロー全体のどこに置くか、リカバリーをどうするかは2FAで詰まるリカバリーコードと復旧の話に詳しく書きました。SMSはあくまで第一歩で、端末を失ったときの復旧まで設計しないと本番で詰みます。
なお、メール経由の確認やマジックリンクと比べたいならSendGridでメール送信を安全に実装する手順が対になります。SMSとメールは、到達性・コスト・即時性のトレードオフが違うので、用途で使い分けてください。
料金と到達でつまずかないために
SMSは1通ごとに課金されるので、メールのような「とりあえず全員に」が効きません。僕が実際に効いた対策を挙げます。
- 長文は分割されて課金が増える: SMSは1通あたりの文字数に上限があり、超えると複数通に分かれます。日本語(マルチバイト)は1通に入る文字数がさらに少ないので、文面は短く。URLは短縮を検討する。
undeliveredを握りつぶさない: Status Callbackでundelivered/failedが来たら、エラーコードを残して原因を切り分ける。番号が無効なのか、キャリアにブロックされたのかで打ち手が違う。- 再送は429/5xxだけ: 上のコードのように、混雑とサーバー障害だけ再送する。番号が間違っている4xxを再送しても、課金が増えるだけで届きません。
- レート制限を自前で持つ: 障害アラートが連鎖すると、同じ宛先に何十通も飛ぶ事故が起きます。短時間の送信上限を決めておく。
到達率は送信元の種類・宛先国・文面に左右されます。固定値で語れないので、本番前に小さなテスト番号で delivered まで追ってから広げてください。
日本からの国際送信で気をつけること
日本のユーザーへ送る場合、前述のとおりこれは 国際A2P SMS になります。国内携帯番号からの送信とは性質が違うので、ここだけ別枠で意識します。
- 差出人の見え方: 番号ではなくAlphanumeric Sender ID(英数字名)で届くことがある。この場合ユーザーは返信できません。双方向のやり取りが要るなら、受信できる送信元を選ぶ。
- キャリアのフィルタ: 国際SMSはスパム対策で弾かれやすい。URLや宣伝色の強い文面は届きにくくなることがある。要件に関わる通知(OTPや発送)は特にテストを厚く。
- OTPはVerify推奨: 国際送信での到達ゆらぎを、Verifyはチャンネル切り替えなどである程度吸収してくれます。自作OTPだと届かなかったときに打つ手がありません。
- 規制と登録: 送信先の国によって送信者登録や同意要件が変わります。最新の要件はTwilio Consoleと公式ドキュメントで都度確認してください。
よくある質問
Q. status: queued のまま届きません。失敗ですか?
A. 失敗とは限りません。queued は受付済みという意味で、配送はこれからです。本当の結果はStatus Callback(Webhook)で delivered や undelivered として届きます。まずWebhookを受ける口を用意して、最終状態を見てください。
Q. 電話番号は 090-1234-5678 のまま渡していいですか?
A. ダメです。APIはE.164形式(+819012345678)を求めます。先頭の0を国番号 +81 に置き換えて正規化します。入力欄には記入例を出し、保存前か送信前に必ず変換してください。
Q. OTPは自分で6桁の乱数を作って送ってはダメ? A. 動きはしますが、有効期限・再送制限・総当たり対策・電話番号変更・監査までやると重くなります。特別な理由がなければTwilio Verifyに寄せて、アプリは検証済み状態の管理だけに集中するのが楽で安全です。
Q. ユーザーからのSMS(受信)はどう受け取りますか?
A. Twilio Consoleで番号の「A MESSAGE COMES IN」にあなたのWebhook URLを設定します。返信が来るとそのURLが呼ばれ、TwiMLで自動応答を返せます。この記事の /twilio/inbound がその実装です。STOPでの配信停止も忘れずに。
Q. 日本のユーザーに、日本の携帯番号を送信元にして送れますか? A. Twilioでは日本国内の携帯番号を送信元にしたSMS送信は基本的に提供されていません。海外番号やAlphanumeric Sender IDからの国際A2P SMSになります。差出人表示や到達が国内番号と異なるので、必ず実機テストしてください。
まとめ
Twilioでやりたいことは、たいてい「送る・受ける・OTPで使う」の3つに収まります。難しいのはコードそのものより、その周りです。queued で安心しないこと、結果はWebhookで後から受けること、番号はE.164に正規化すること、OTPはVerifyに任せること、そして日本宛は国際送信として別扱いすること。ここを押さえれば、サンプルが事故の入口になる確率はぐっと下がります。
僕がこの構成をローカルで回した結果、ZodのE.164チェック、eventId での二重送信防止、Status Callbackと着信SMSの署名検証つき受信、マスク済みログまでは確認できました。実際の配送は認証情報・送信元・宛先国・その時点のTwilioルール次第なので、本番前に必ず自分の番号宛に1通送り、Message SIDとStatus Callbackを最後まで追ってください。SMS連携を含むAPI実装や運用設計をチームで固めたいときは、Claude Code研修・導入相談でも扱っています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。