本記事はFIXERが提供する「cloud.config Tech Blog」に掲載された「Twilio×NestJS による SMS&架電」を再編集したものです。
はじめに
皆さんこんにちは。4月で2年目になってしまう川村です。
来月初めての後輩が入ってくると思うとわくわくする反面、自分が先輩としてやっていけるのかという不安もあります。。。
そんな中ですが、案件が落ち着いたので備忘録もかねて実務で使用していた技術についての記事を書いてみようと思いました。
さっそく本題にはいりましょう。
「SMS や自動音声通話って、自分で実装するのは難しそう...」と思っていませんか?
実は Twilio を使えば、ほんの数十行のコードで自分のスマホに SMS を送ったり、 電話をかけたりできます。
この記事では、最小コードで動かすところから、 BullMQ によるキュー / リトライ / Webhook 送達確認 / 動的 Cron スケジューリングまでを段階的に解説します。
対象読者
・NestJS(TypeScript)で開発した経験がある方
・SMS や自動架電を自前で実装してみたい方
この記事でできること
1. 最小コードで SMS 送信・架電を体験する(Part 1)
2. NestJS サービスとして DI で使える形にする(Part 1)
3. BullMQ によるキュー・リトライ・送達確認・スケジューリングを構築する(Part 2)
事前に知っておくこと
本記事ではTwilioのトライアルアカウントを使用します。Twilio は従量課金制ですが、トライアルアカウントでは最初に一定の無料クレジットが付与されるため、検証用途には十分です。
あわせて、以下の点に注意してください。
・料金について
SMS・架電ともに1通(1コール)ごとに料金が発生します。単価は国やキャリアによって異なります。
・トライアルアカウントの制限
トライアルにはいくつか制限があります(例:送信先の制限、無料クレジット、音声機能の制約など)。詳細は後述します。
・Twilio Console について
Twilio はよく UI が変わるので、本記事で記述しているページが存在しない、または移動している可能性があります。
・顧客への送信時の注意(同意・配信停止)
SMS/架電を顧客に送る場合、事前同意の取得と配信停止(オプトアウト)の導線が必要です。
法律・ガイドラインの詳細は業種や地域によって異なるため、必要に応じて社内法務や顧問に確認してください。
・個人情報(PII)の取り扱い
電話番号は個人情報(PII)に該当します。保存方針やログ出力ルール(マスキング可否・保管期間など)を事前に決めておきましょう。
Part 1: 最小コードで SMS 送信・架電を体験する
1. まずは準備 — Twilio アカウントと環境構築
Twilio を使い始めるのに必要なのは、アカウント登録と 3 つの環境変数だけです。
① Twilio に無料登録
twilio.com/try-twilio からアカウントを作成します。
トライアルアカウントについて
アカウントを作成すると、すぐに無料クレジット($15 USD 程度)が付与されます。
また、電話番号が 1 つ自動で割り当てられます(米国番号)。
この番号が SMS の送信元・架電の発信元になります。
トライアルアカウントには以下の制限があります。
| 制限 | 内容 |
| 送信先 | 自分で認証(Verify)した電話番号にのみ送信可能 |
| SMS プレフィックス | SMS の冒頭に「Sent from your Twilio trial account -」が自動付与される |
| 日本語音声(TTS) | 利用不可。架電自体は可能だが、Polly.Mizuki 等の日本語ボイスはトライアルでは使えない |
| 電話番号 | 割り当てられるのは米国番号。日本番号の取得にはアップグレードが必要 |
日本語音声を使いたい場合
トライアルでは <Say voice="Polly.Mizuki"> による日本語 TTS が使えません。
日本語の自動音声通話をテストするには、アカウントをアップグレード(従量課金)する必要があります。
SMS の送受信はトライアルでも問題なく動作するので、まずは SMS から試してみるのがおすすめです。
② Account SID / Auth Token / 電話番号を取得
Twilio Console のダッシュボードの Account Info に表示される 3 つの値をメモします。
1. Account SID
2. Auth Token
3. My Twilio Phone Number
③ 電話番号を検証
トライアルアカウントでは、検証済みの電話番号にしかSMS/架電を送ることができません。
Twilio Console > Developタグ > # Phone Numbers > Manage > Verified Caller IDs ページの Add a new Caller ID ボタンから自身の電話番号を追加してください。( E.164形式で追加することに注意してください。先頭の0を消して81を付ければOKです。080 -> +8180, 0120 -> +81120 となります。)
E.164 形式って?
Twilio では電話番号を +{国番号}{番号} の形式で指定します。
日本の 090-1234-5678 なら +819012345678 です。
先頭の 0 を取って +81 を付けるだけですね。
④ パッケージをインストール
npm install twilio
⑤ .env に認証情報をセット
# Twilio (値は自分のものに置き換えてください)
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_PHONE_NUMBER=+1XXXXXXXXXX
# 検証済みの電話番号(宛先)
TO_PHONE_NUMBER=+818091234567
絶対にコードに直書きしない
TWILIO_ACCOUNT_SID と TWILIO_AUTH_TOKEN は API の認証情報です。
漏洩すると第三者があなたのアカウントで自由に SMS を送信・架電できてしまいます。
.env は必ず .gitignore に含めてください。
これだけで準備は完了です。さっそく SMS を送ってみましょう。
2. 自分のスマホに SMS を送ってみる
まずは最小限のコードで、自分の電話番号に SMS を 1 通送ってみます。
NestJS のことは一旦忘れて、たった十数行で動きます。(本来は環境変数の検証処理も書くべきなのですが、ご容赦ください。。。)
// send-sms.ts — そのままコピペで動きます
import Twilio from 'twilio';
import 'dotenv/config'; // .env を自動読み込み
async function main() {
const client = Twilio(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!,
);
const message = await client.messages.create({
body: 'こんにちは!Twilio からのテスト SMS です。',
from: process.env.TWILIO_PHONE_NUMBER!, // Twilio の電話番号
to: process.env.TO_PHONE_NUMBER!,
});
console.log('送信成功! SID:', message.sid);
console.log('ステータス:', message.status); // "queued"
}
main().catch(console.error);
実行方法
npx tsx send-sms.ts で即実行できます。
dotenv/config が .env を自動で読むので、環境変数の手動セットは不要です。
実行結果
数秒後にスマホに SMS が届きます。
logに出力されている message.sid には SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 形式の ID が入っています。
この SID を使えば、後からメッセージのステータス(配信済み・失敗など)を確認できます。(SIDを使わず、コンソールから Monitor > Logs > Messaging ページでも確認できます。)
ステータスを確認してみる
送信直後の status は "queued"(Twilio が受け付けた状態)です。
実際に届いたかは少し後に確認できます。
// 送信後、数秒待ってから(main() 内の続き) const updated = await client.messages(message.sid).fetch(); console.log(updated.status); // "delivered" なら配信成功!
| ステータス | 意味 |
| queued | Twilio が受け付けた |
| sent | キャリアに送信済み |
| delivered | 端末に配信成功 |
| undelivered | 配信失敗 |
| failed | 送信自体が失敗 |
3. 自分のスマホに電話をかけてみる
次は架電です。SMS と同じくらい簡単で、テキストを渡すだけで日本語の自動音声が流れます。
※トライアルアカウントでは固定のボイス( This is a trial account ~ って流れます)が流れます。
Twilio は内部で TwiML という XML 形式を使って通話の内容を制御します。
// make-call.ts
import Twilio from 'twilio';
import 'dotenv/config';
async function main() {
const client = Twilio(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!,
);
const call = await client.calls.create({
to: process.env.TO_PHONE_NUMBER!, // 自分のスマホ
from: process.env.TWILIO_PHONE_NUMBER!,
twiml: `
<Response>
<Say voice="Polly.Mizuki" language="ja-JP">
こんにちは。Twilio からのテスト通話です。
</Say>
</Response>
`,
});
console.log('架電開始! SID:', call.sid);
}
main().catch(console.error);
実行結果
スマホに着信があり、電話に出ると音声が流れます。
Polly.Mizuki は Amazon Polly の日本語女性ボイスで、 自然な発音で読み上げてくれます。
架電自体はトライアルでも可能ですが、日本語 TTS のテストにはアカウントのアップグレードが必要です。
TwiML のポイント
・<Say>: テキストを音声に変換して再生
・voice="Polly.Mizuki": 日本語ボイスを指定(他に Polly.Takumi もあり)
・language="ja-JP": 言語を明示
twiml にインラインで XML を渡す方法以外に、url パラメータで 外部の TwiML URL を指定する方法もあります。
より複雑なフロー(IVR など)を作る場合はそちらが便利です。
通話ステータス
| ステータス | 意味 |
| completed | 通話完了(duration > 0 なら応答あり) |
| busy | 通話中で出られなかった |
| no-answer | 応答なし |
| failed | 発信失敗 |
| canceled | キャンセルされた |
4. 本番に進む前に — 落とし穴と注意点
ここまでで「動く」ところまで来ました。
ここからは運用を見据えた実装をしていきましょう。
本番に進む前に、ハマりやすいポイントを先に押さえておきましょう。
電話番号の E.164 変換
DB やユーザー入力は 090-1234-5678 形式で来ることが多いですが、 Twilio は E.164 形式 (+819012345678) を要求します。
変換ユーティリティを用意しておくと安全です。
/** 日本の電話番号 → E.164 形式 "090-1234-5678" → "+819012345678" */
function toE164(phone: string): string {
let cleaned = phone.replace(/[-\s\u3000()()]/g, ''); // ハイフン・空白・全角カッコを除去
if (cleaned.startsWith('0')) {
cleaned = '+81' + cleaned.substring(1);
} else if (!cleaned.startsWith('+')) {
cleaned = '+81' + cleaned;
}
return cleaned;
}
/** E.164 形式 → 国内形式 "+819012345678" → "09012345678" */
function toLocal(phone: string): string {
let cleaned = phone.replace(/[-\s]/g, '');
if (cleaned.startsWith('+81')) {
cleaned = '0' + cleaned.substring(3);
}
return cleaned;
}
/** E.164 形式かどうかバリデーション */
function isE164(phone: string): boolean {
return /^\+[1-9]\d{1,14}$/.test(phone);
}
よくあるハマりパターン
・全角数字・全角ハイフン: ユーザー入力で混入しやすい。正規化を忘れると E.164 バリデーションに落ちる
・既に +81 が付いた番号: 二重変換で +8181... になる事故。startsWith('+') の分岐で防ぐ
・固定電話(03-XXXX-XXXX 等): E.164 変換は同じロジックで OK だが、SMS は携帯番号にしか届かない
コストと課金
Twilioは完全従量課金です。意図しないコスト増を防ぐために以下を把握しておきましょう。
| 項目 | 目安 | 注意点 |
| SMS(日本宛) | $0.07〜/通 | キャリア・番号種別で変動 |
| 架電(日本宛) | $0.02〜/分 | 通話時間 + 接続料 |
| 電話番号維持 | $1〜/月 | 米国番号は安い、日本番号は高め |
| Status API 呼び出し | 無料 | ただしレートリミットあり |
バルク送信時はレートリミット(後述)だけでなく、送信件数 × 単価 を事前に見積もりましょう。
テスト時にループで 1,000 通送ってしまう...といった事故は案外起きます。
同意とコンプライアンス
SMS や架電を顧客・ユーザー向けに送る場合は、以下の点を確認してください。
・事前同意: 通知を送ることについて、ユーザーから明示的な同意を得ているか
・配信停止: 「STOP」返信や設定画面でオプトアウトできる導線があるか
・送信時間帯: 深夜・早朝に架電/SMS を送らない制御を入れているか
法律の詳細はサービスの業種・地域で異なります。断定はしませんが、 社内法務や顧問と事前に確認しておくことを強く推奨します。
5. NestJS サービスとしてまとめる
ここまでの処理を NestJS の Injectable サービスにまとめると、 どこからでも DI で呼び出せるようになります。
escapeXml — TwiML インジェクション対策
架電の <Say> にユーザー入力が含まれる場合、 XML の特殊文字をエスケープしないと TwiML が壊れたり、意図しないタグが注入される可能性があります。
サービスに組み込む前に、このユーティリティを用意しておきましょう。
/** TwiML に埋め込む文字列を安全にエスケープ */
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
@Injectable()
export class TwilioService {
private client: Twilio.Twilio;
private fromPhoneNumber: string;
constructor(private readonly configService: ConfigService) {
const accountSid = this.configService.get('TWILIO_ACCOUNT_SID');
const authToken = this.configService.get('TWILIO_AUTH_TOKEN');
this.fromPhoneNumber = this.configService.get('TWILIO_PHONE_NUMBER');
this.client = Twilio.default(accountSid, authToken);
}
/** SMS を送信 */
async sendSms(to: string, body: string) {
const formatted = isE164(to) ? to : toE164(to);
const message = await this.client.messages.create({
body,
from: this.fromPhoneNumber,
to: formatted,
});
return { sid: message.sid, status: message.status };
}
/** 架電(日本語 TTS) */
async makeCall(to: string, message: string) {
const formatted = isE164(to) ? to : toE164(to);
const twiml = `<Response>
<Say voice="Polly.Mizuki" language="ja-JP">
${escapeXml(message)}
</Say>
</Response>`;
const call = await this.client.calls.create({
to: formatted,
from: this.fromPhoneNumber,
twiml,
});
return { sid: call.sid, status: call.status };
}
/** 一括 SMS(レートリミット付き) */
async sendBulkSms(messages: { to: string; body: string }[]) {
const results = [];
for (const msg of messages) {
results.push(await this.sendSms(msg.to, msg.body));
await new Promise(r => setTimeout(r, 100)); // 100ms 間隔
}
return results;
}
}
コントローラーや他のサービスから呼ぶときはこれだけです。
// SMS を送る
await this.twilioService.sendSms('090-1234-5678', '予約のリマインダーです');
// 電話をかける
await this.twilioService.makeCall('090-1234-5678', 'ご予約の確認です');
ここまでのまとめ
ここまでで、NestJS アプリケーションから SMS 送信・架電ができるようになりました。
個人開発や小規模なプロジェクトならこれで十分です。
ここから先は、大量送信・リトライ・ログ管理・スケジューリングといった 運用を見据えたアーキテクチャを紹介します。
川村達矢/FIXER
2025年4月入社の川村です!
伊藤達矢さんと同じ漢字なので、覚えていただけると嬉しいです!
本記事はアフィリエイトプログラムによる収益を得ている場合があります


この連載の記事
-
TECH
Next.jsで静的テスト環境を構築し、GitHub Actionsで自動化してみた -
TECH
ゆるく理解する自作シェル実装1:そもそもシェルってどんなもの? -
TECH
プロンプトエンジニアリングのコツは「5W1Hを忘れずに」 -
TECH
GitHubの 超・超・超 基本的な使い方まとめ -
TECH
業務で使えるExcel関数テクニック − 関数を使った動的な範囲指定のコツ -
TECH
zshの初期設定がダサい…。表示内容を自分好みにカスタマイズしていく -
TECH
Proxmox VE+OpenMediaVaultで自宅用NASを作ってみた -
TECH
Chrome拡張はVue.jsで作るのがおすすめ -
TECH
gitコマンド、長いしだるいしMMS(マジ・短く・したい) -
TECH
Terraformのバージョン管理ツール、古いtfenvからtenvへの移行 -
TECH
「SOSの出し方を知ろう」 新卒入社から1年、学んだことを振り返る - この連載の一覧へ



