Twilio×NestJS による SMS&架電
2026-03-05
azblob://2026/03/02/eyecatch/2026-03-02-send-sms-and-automated-calls-from-nestjs-using-twilio-and-bullmq-000.png

はじめに

皆さんこんにちは。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_SIDTWILIO_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" なら配信成功!
ステータス意味
queuedTwilio が受け付けた
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,  '&amp;')
    .replace(/</g,  '&lt;')
    .replace(/>/g,  '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}
@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 送信・架電ができるようになりました。
個人開発や小規模なプロジェクトならこれで十分です。

ここから先は、大量送信・リトライ・ログ管理・スケジューリングといった 運用を見据えたアーキテクチャを紹介します。


Part 2: 運用したい人へ

ここからは、本番運用で求められる要件を解決するアーキテクチャを解説します。

※このあたりから先は、私(新卒)が実務で触ったコードの抜粋です。環境依存の部分や省略があるので、そのままコピペで動くことは保証できませんが、設計の考え方・組み立て方の参考になれば幸いです。


ここでは BullMQ(Redis ベースのジョブキュー)を中心に、 Producer-Consumer パターンで構築していきます。

このアーキテクチャのメリット:

  • 非同期処理: API レスポンスを送信完了まで待たせない
  • 自動リトライ: 指数バックオフ(2s → 4s → 8s)で最大 3 回
  • 水平スケーリング: Worker を複数台起動可能
  • 送達確認: ステータスチェックジョブで配信成否を自動追跡

6. なぜキューが必要か — BullMQ でバッチ処理・リトライ

SMS 100 件を同期的に送ると、API レスポンスが数十秒〜数分ブロックされます。
途中で 1 件失敗しただけで全体がロールバック...というのも避けたい。
ジョブキューに投げれば、API は即座にレスポンスを返し、送信・リトライは Worker が非同期で処理します。

BullMQ は Redis ベースのジョブキューで、 NestJS とは @nestjs/bullmq でシームレスに統合できます。

セットアップ

npm install @nestjs/bullmq bullmq

 

モジュール設定 — リトライ・自動クリーンアップ

BullModule.forRootAsync({
  useFactory: async (configService: ConfigService) => ({
    connection: configService.getRedisConnectionConfig(),
    defaultJobOptions: {
      removeOnComplete: { age: 3600, count: 100 }, // 1h 後削除, 100件保持
      removeOnFail:     { age: 86400, count: 500 }, // 24h 後削除, 500件保持
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 }, // 2s → 4s → 8s
    },
  }),
});

 

キュー定義

この例では 18 キューを用途別に分離しています。 SMS・架電に関わるものは以下の 4 つです。

export const QUEUE_NAMES = {
  SMS_NOTIFICATION:          'sms_notification',          // 単発 SMS・架電
  PRESCRIPTION_SMS_REMINDER:  'prescription_sms_reminder',  // 定期リマインダー (SMS)
  PRESCRIPTION_CALL_REMINDER: 'prescription_call_reminder', // 定期リマインダー (架電)
  TWILIO_STATUS_CHECK:        'twilio_status_check',        // 送達確認バッチ
  // ... LINE, Email, 決済, レポート等のキュー
} as const;

 

Processor — ジョブを受け取って送信

@Processor デコレータでキューと紐づけると、
ジョブが投入されたタイミングで process() が自動的に呼ばれます。

@Processor(QUEUE_NAMES.SMS_NOTIFICATION)
export class SmsNotificationProcessor extends WorkerHost {

  constructor(
    private readonly twilioService: TwilioService,
    private readonly logService: AutoReminderLogService,
    @InjectQueue(QUEUE_NAMES.TWILIO_STATUS_CHECK)
    private readonly statusCheckQueue: Queue,
  ) { super(); }

  async process(job: Job) {
    switch (job.name) {
      case 'send-sms':   return this.handleSms(job.data);
      case 'make-call':  return this.handleCall(job.data);
      case 'send-bulk-sms': return this.handleBulkSms(job);
    }
  }

  private async handleSms(data) {
    // ① 送信
    const result = await this.twilioService.sendSms(data.to, data.body);

    // ② ログ保存(PENDING で記録)
    await this.logService.logSmsSend({
      phoneNumber: toLocal(data.to),
      message: data.body,
      success: !!result.sid,
      twilioSid: result.sid,
    });

    // ③ 5分後にステータスチェックを予約
    //    直近 PENDING のログを一括チェックする設計なので、
    //    ジョブデータには起点時刻だけ渡せば十分
    if (result.sid) {
      await this.statusCheckQueue.add('check-status', {
        triggeredAt: new Date().toISOString(),
      }, {
        delay: 5 * 60 * 1000,  // 5分後
      });
    }

    return result;
  }

  // バルク送信時は進捗をリアルタイム更新
  private async handleBulkSms(job: Job) {
    const recipients = job.data.to;
    for (let i = 0; i < recipients.length; i++) {
      await job.updateProgress((i / recipients.length) * 100);
      await this.handleSms({ to: recipients[i], body: job.data.body });
    }
    await job.updateProgress(100);
  }
}

job.updateProgress() の活用

バルク送信時に進捗率を更新しておくと、管理画面側で QueueEventsprogress イベントをリッスンして
プログレスバーを表示できます。

7. なぜ PENDING で保存するか — 非同期ログと送達追跡

Twilio の送信 API が 200 OK を返しても、それは「Twilio が受け付けた」だけで「届いた」ではありません。
送信直後のステータスは PENDING で保存し、あとから確定させるのがこの設計のポイントです。


@Injectable()
export class AutoReminderLogService {

  async logSmsSend(params: {
    phoneNumber: string;
    message: string;
    success: boolean;
    twilioSid?: string;
    targetId?: number;
    templateId?: number;
  }) {
    // SID がある = Twilio が受け付けた → まだ配信未確定 → PENDING
    // SID がない = API エラー → 即 FAILED
    const status = params.twilioSid
      ? 'PENDING'
      : 'FAILED';

    return this.logRepository.save({
      channel: 'SMS',
      status,
      phone_number: params.phoneNumber,
      message: params.message,
      twilio_sid: params.twilioSid,
      sent_at: new Date(),
    });
  }

  // 架電も同じパターン
  async logCallSend(params) {
    const status = params.twilioSid ? 'PENDING' : 'FAILED';
    return this.logRepository.save({
      channel: 'CALL', status, ...
    });
  }
}

 

テーブル構造

カラム説明
idSERIAL主キー
channelENUMSMS / CALL / LINE_TEXT / LINE_CARD
statusENUMPENDING / SUCCESS / FAILED
twilio_sidVARCHAR(50)Twilio SID(ステータス照合のキー)
phone_numberVARCHAR(20)国内形式で保存
messageTEXT送信内容
twilio_error_codeVARCHAR(20)Twilio エラーコード
error_messageTEXT失敗理由(日本語)
sent_atTIMESTAMP送信日時

8. 送達ステータスを確定する — Webhook と TEMP TABLE

本来の正攻法: StatusCallback Webhook

Twilio には、SMS や架電のステータスが変わったタイミングで こちらのサーバーに HTTP POST を送ってくれる Webhook(StatusCallback)の仕組みがあります。
送信時に statusCallback パラメータで URL を指定するだけです。

// SMS 送信時に StatusCallback を指定
const message = await client.messages.create({
  body: 'テスト',
  from: process.env.TWILIO_PHONE_NUMBER,
  to:   '+8190XXXXXXXX',
  statusCallback: 'https://your-server.com/api/twilio/webhook/sms-status',
});

// 架電時に StatusCallback を指定
const call = await client.calls.create({
  to:   '+8190XXXXXXXX',
  from: process.env.TWILIO_PHONE_NUMBER,
  twiml: '...',
  statusCallback: 'https://your-server.com/api/twilio/webhook/call-status',
  statusCallbackEvent: ['completed', 'busy', 'no-answer', 'failed'],
});

Twilio がステータス変更のたびに POST してくれるので、 バックエンド側は受け取ったデータで DB を更新するだけです。
リアルタイムにステータスが反映され、ポーリングの遅延やAPIコール数の無駄もありません。

Webhook が使えるなら、バックエンドからのポーリングは避けるべき

この後紹介する「TEMP TABLE で一括ステータス更新」は、 バックエンドから Twilio の List API を定期的に叩いてステータスを取得する方式です。
StatusCallback Webhook が利用できる環境であれば、Webhook を使うのが正攻法です。
ポーリング方式には以下のデメリットがあります。

  • ステータス反映にタイムラグがある(ジョブの実行間隔に依存)
  • Twilio API の呼び出し回数が増え、レートリミットに抵触するリスク
  • 送信件数が多いほど List API のレスポンスが大きくなる

以下のパターンは、Webhook を受けられない環境(ファイアウォール制約、ローカル開発など)や Webhook の取りこぼしに対するフォールバックとして参考にしてください。

フォールバック: TEMP TABLE で一括更新

送信から 5 分後に BullMQ のジョブが起動し、Twilio の List API でステータスを一括取得。
PostgreSQL の TEMP TABLE を使って効率的にバルク更新します。

なぜ TEMP TABLE?

100 件の SMS を送ったら、ステータス確認も 100 回 UPDATE? ...いいえ。
TEMP TABLE にまとめて INSERT → 本テーブルと JOIN して 1 回の UPDATE で済ませます。

  1. Twilio List API でメッセージ/通話を一括取得
  2. CREATE TEMP TABLE ... ON COMMIT DROP で一時テーブル作成
  3. 取得結果をバルク INSERT
  4. 本テーブルと JOIN して 1 回の UPDATE
  5. DB にない SID は INSERT で補完(安全弁)

 

※ SQLインジェクションに注意してください。

ステータスチェックで TEMP TABLE にデータを挿入する際、Twilio からの値を SQL に埋め込んでいます。
SID のフォーマットバリデーション (/^(SM|CA)[0-9a-f]{32}$/) を追加するか、 パラメータ化クエリを使うことを推奨します。

@Processor(QUEUE_NAMES.TWILIO_STATUS_CHECK)
export class TwilioStatusCheckProcessor extends WorkerHost {

  async process(job: Job) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();

    // ① Twilio から一括取得
    const messages = await this.twilioService.listMessages({
      from: this.fromPhoneNumber,
      dateSentAfter: startTime,
      limit: fetchLimit,
    });

    await queryRunner.startTransaction();
    try {
      // ② TEMP TABLE 作成(トランザクション終了で自動削除)
      await queryRunner.query(`
        CREATE TEMP TABLE temp_twilio_status (
          sid VARCHAR(50) PRIMARY KEY,
          status VARCHAR(20) NOT NULL,
          error_code VARCHAR(20),
          error_message TEXT
        ) ON COMMIT DROP
      `);

      // ③ バルク INSERT
      await queryRunner.query(`INSERT INTO temp_twilio_status VALUES ...`);

      // ④ 本テーブルを一括 UPDATE
      await queryRunner.query(`
        UPDATE auto_reminder_logs AS arl
        SET
          status = CASE
            WHEN tmp.status = 'delivered' THEN 'SUCCESS'
            WHEN tmp.status IN ('failed', 'undelivered') THEN 'FAILED'
            ELSE arl.status
          END,
          twilio_error_code = tmp.error_code,
          error_message = tmp.error_message,
          updated_at = NOW()
        FROM temp_twilio_status AS tmp
        WHERE arl.twilio_sid = tmp.sid
          AND arl.status = 'PENDING'
      `);

      await queryRunner.commitTransaction();
    } catch (e) {
      await queryRunner.rollbackTransaction();
      throw e;
    } finally {
      await queryRunner.release();
    }
  }
}

 

架電のステータス判定

架電の場合、completed でも通話時間 0 秒なら実質「出ていない」可能性があります。
また、失敗理由を日本語で保存して管理画面の可読性を上げています。

status = CASE
  WHEN tmp.status = 'completed' AND tmp.duration > 0 THEN 'SUCCESS'
  WHEN tmp.status IN ('busy', 'no-answer', 'failed', 'canceled') THEN 'FAILED'
  ELSE arl.status
END,
error_message = CASE
  WHEN tmp.status = 'busy'      THEN '通話中'
  WHEN tmp.status = 'no-answer' THEN '応答なし'
  WHEN tmp.status = 'failed'    THEN '通話失敗'
  WHEN tmp.status = 'canceled'  THEN 'キャンセル'
END

QueryRunner が必要な理由

TEMP TABLE は作成したコネクション内でしか見えません。
TypeORM のデフォルトではクエリごとにコネクションが変わりうるため、 createQueryRunner() で同一コネクションを保証しています。

9. なぜ DB 駆動か — 動的 Cron スケジューラ

Cron 式をコードにハードコーディングすると、スケジュール変更のたびにデプロイが必要になります。
スケジュールを DB テーブルで管理すれば、管理画面から自由に追加・変更・停止でき、デプロイ不要です。

@Injectable()
export class AutoReminderSchedulerService implements OnModuleInit {

  // Worker 起動時に DB のスケジュールを読み込んで Cron ジョブに変換
  async onModuleInit() {
    await this.syncSchedulesFromDB();
  }

  async syncSchedulesFromDB() {
    // 既存のリピートジョブをクリア
    await this.clearAllAutoReminderJobs();

    // DB からアクティブなスケジュールを取得
    const schedules = await this.dataSource.query(`
      SELECT id, channel, execution_time, template_id
      FROM auto_reminder_schedules
      WHERE is_active = true
    `);

    // 各スケジュールを BullMQ の Cron ジョブとして登録
    for (const s of schedules) {
      const cron = this.timeToCron(s.execution_time);
      // "10:30:00" → "30 10 * * *"

      const queue = this.getQueueByChannel(s.channel);
      await queue.add('auto-reminder', {
        scheduleId: s.id,
        channel: s.channel,
        templateId: s.template_id,
      }, {
        repeat: { pattern: cron, tz: 'Asia/Tokyo' },
      });
    }
  }

  private timeToCron(time: string): string {
    const [h, m] = time.split(':').map(Number);
    return `${m} ${h} * * *`;
  }
}

tz: 'Asia/Tokyo' を忘れずに

BullMQ の repeat.tz を省略すると UTC 解釈になります。
10:30 JST のつもりが 19:30 JST に実行される...という事故が起きます。

管理画面からスケジュールが変更された際は、API 側から syncSchedulesFromDB() を再度呼び出すことで、
Cron ジョブを動的に再登録しています。

Worker 複数台構成での注意

onModuleInit() は各 Worker インスタンスで実行されるため、 複数台構成では同じ Repeat ジョブが重複登録される可能性があります。
BullMQ の Repeat は同一キー(pattern + jobName)なら重複しない設計ですが、 clearAllAutoReminderJobs() と再登録が競合するとタイミング次第でジョブが消えるケースもあります。
本番では分散ロック(Redlock 等)やリーダー選出で、 同期処理を 1 インスタンスだけに限定することを検討してください。

10. セキュリティ・PII・コンプライアンス

Twilio を運用する上で押さえておくべきセキュリティと個人情報保護のポイントをまとめます。

クレデンシャルの管理

  • .env.gitignore に含め、Git に絶対にコミットしない
  • ConfigService 経由で読み込み、コード中にハードコーディングしない
  • 本番環境では AWS Secrets Manager 等の秘密管理サービスを利用
  • ログに Auth Token を出力しない(Logger のフィルタリング)

電話番号のバリデーション

不正な番号が Twilio に渡されると、予期しない国際通話やエラーの原因になります。
送信前に必ず E.164 形式のバリデーションを行いましょう。

レートリミット

意図しない大量送信を防ぐために、送信間隔を制御しています。

チャネル送信間隔理由
SMS100msTwilio API Concurrency 上限の回避
架電500ms同上 + 架電は API 処理が重い

PII(個人情報)の扱い

電話番号は個人情報です。ログ出力・DB 保存の両面でルールを決めておきましょう。

項目ログ出力理由
TWILIO_ACCOUNT_SID禁止認証情報
TWILIO_AUTH_TOKEN禁止認証情報
電話番号(フル)マスク推奨個人情報
SMS 本文(フル)先頭50文字まで機微情報を含みうる
Twilio SIDOKステータス追跡に必要
エラーコードOK障害調査に必要

電話番号のマスキング例:

/** 電話番号の末尾 4 桁だけ表示し、残りをマスクする */
function maskPhone(phone: string): string {
  if (phone.length <= 4) return '****';
  return '*'.repeat(phone.length - 4) + phone.slice(-4);
}
// "09012345678" → "*******5678"

// ログ出力
this.logger.log(`SMS sent to ${maskPhone(to)}: ${body.substring(0, 50)}...`);

保存の最小化 — テンプレート ID パターン

SMS 本文をそのまま DB に全文保存するのではなく、 テンプレート ID + パラメータの形で保持する設計も有効です。
例: { templateId: 3, params: { name: "山田", date: "3/5" } }
ログテーブルに生の本文を持たないことで、PII の保存範囲を最小化できます。

おわりに

この記事で扱った内容を振り返ります。

パートできるようになったこと
Part 1Twilio SDK で SMS 送信・架電を最小コードで実行
Part 1E.164 変換・escapeXml で安全に送信
Part 1NestJS の Injectable サービスとして DI 可能に
Part 2BullMQ で非同期バッチ送信 + 指数バックオフリトライ
Part 2PENDING → SUCCESS/FAILED の非同期ステータス追跡
Part 2StatusCallback Webhook(正攻法)+ TEMP TABLE(フォールバック)
Part 2DB 駆動の動的 Cron スケジューラ

Part 1 で見たとおり、Twilio を使えば ほんの十数行で SMS も架電も送れます。
「まず自分のスマホに送ってみる」ところから始めれば、ハードルは思ったより低いはずです。

一方で、プロダクションに載せるにはリトライ・ログ・送達確認・スケジューリング・セキュリティと 考えることが一気に増えます。
Part 2 で紹介した BullMQ + Webhook + DB スケジューラのパターンが、 その課題に対するひとつの設計例として参考になれば幸いです。