Twilio ConversationRelay の WebSocket 通信の署名検証で詰まった話

Twilio ConversationRelay の WebSocket 通信の署名検証で詰まった話

Twilio ConversationRelay の WebSocket 接続に自前で署名検証を実装したところ、Upgrade リクエストの検証だけが通らず、通話が繰り返しエラーで切断されました。原因は、TwiML に指定した wss のスキームのまま署名対象 URL を計算する必要があったことでした。
2026.08.15

はじめに

音声 AI ボットの検証作業で Twilio ConversationRelay を自前の WebSocket サーバーで受ける実装をしていたところ、WebSocket の Upgrade リクエストの署名検証だけが通らず、通話が繰り返しエラーで切断される現象に遭遇しました。POST webhook 向けに組み立てた URL の作り方を、そのまま Upgrade リクエストの署名検証にも流用したことが原因でした。

署名対象の URL を https:// に変換して計算すると、Twilio から届く実際の署名と一致しませんでした。TwiML の <ConversationRelay url="wss://..."> に指定したとおり、wss:// のスキームのまま計算する必要がありました。

ConversationRelay の WebSocket 署名検証とは

ConversationRelay は、Twilio の音声通話を WebSocket で自分のサーバーへつなぐしくみです。サーバー側では、TwiML の <Connect><ConversationRelay> に応じて Twilio から届く WebSocket の Upgrade リクエストを受け付けます。Twilio は、接続元を検証するため、この Upgrade リクエストにも X-Twilio-Signature を検証するよう案内しています

検証環境

  • WebSocket サーバー: プレーンな Node.js + ws + twilio パッケージ
  • Node.js ランタイム: v24.18.0
  • デプロイ先: Vercel Functions

対象読者

  • Twilio ConversationRelay を自前のサーバーで受けようとしている方
  • WebSocket の Upgrade リクエストに X-Twilio-Signature の検証を実装しようとしている方
  • 原因のわからない署名検証エラーに困っている方

参考

事象: 署名検証エラーで通話が切断される

WebSocket の Upgrade リクエストに対する署名検証を実装しました。POST webhook 向けに組み立てていた requestUrl = PUBLIC_BASE_URL + req.url (PUBLIC_BASE_URLhttps:// で始まる) という URL の組み立て方を、そのまま流用しました。

実際に電話をかけると、通話が error 64102 (Unable to connect to websocket URL) で切断されました。着信音は鳴りますが、TwiML の応答後に接続が確立しません。

サーバー側のログを見ると、/voice (POST) は署名検証を通過して正常処理まで進んでいましたが、/ws へのアップグレードリクエストだけ invalid_signature で 403 を返していました。同じ TWILIO_AUTH_TOKEN を使っているのに POST 側だけ通っていたのが、調査の手がかりになりました。

調査: 実際の署名を突き合わせる

デバッグ用のログを一時的に追加し、実際に Twilio から届いた署名の値と、サーバー側で計算した期待値を突き合わせたところ、両者は一致しませんでした。

そこで、ローカルで Node.js のスクリプトを書き、複数の URL 候補について HMAC-SHA1 で署名を計算し、実際に受信した署名と突き合わせました。

const crypto = require('crypto');

function sign(url, params = {}) {
  const data = Object.keys(params).sort().reduce((acc, key) => acc + key + params[key], url);
  return crypto.createHmac('sha1', AUTH_TOKEN).update(Buffer.from(data, 'utf-8')).digest('base64');
}

const candidates = [
  'https://example.vercel.app/ws',
  'wss://example.vercel.app/ws',
  'https://example.vercel.app/ws/',
  'https://example.vercel.app:443/ws',
];

for (const url of candidates) {
  console.log(url, sign(url) === RECEIVED_SIGNATURE ? 'MATCH' : 'no match');
}

wss:// のスキームのまま計算した候補だけが、実際に受信した署名と一致しました。

原因: 署名対象 URL のスキーム

TwiML の <ConversationRelay url="wss://..."> に指定した URL が、そのまま署名の計算対象になっていました。POST webhook の検証で使う https:// への変換は、WebSocket の Upgrade リクエストには当てはまりませんでした。

対策: 修正後のコード

署名対象 URL のスキームだけを https:// から wss:// に変更し、再デプロイしました。

api/index.js
function validateGetRequest(req) {
  const signature = req.headers['x-twilio-signature'];
  const requestUrl = PUBLIC_BASE_URL.replace(/^http/, 'ws') + req.url;
  return twilio.validateRequest(AUTH_TOKEN, signature, requestUrl, {});
}

実際の通話で setup イベントを正常に受信できることを確認しました。

まとめ

Twilio ConversationRelay の WebSocket 接続に自前で署名検証を実装したところ、POST webhook 向けの URL の組み立て方をそのまま流用したことが原因で、Upgrade リクエストの検証だけが通らず、通話が繰り返しエラーで切断されました。原因は、TwiML に指定した wss:// のスキームのまま署名対象 URL を計算する必要があったことです。

ConversationRelay の WebSocket 接続に X-Twilio-Signature の検証を自前で実装する場合は、POST webhook 用のコードをそのまま流用せず、署名対象 URL のスキームを確認することをおすすめします。


Twilioの導入支援はクラスメソッドにお任せください!

クラスメソッドでは、Twilioの導入から運用までしっかりサポートいたします。
コミュニケーションツールの導入や最適化をお考えの方は、ぜひお気軽にご相談ください。

Twilioの詳細を見る

この記事をシェアする

関連記事