Webhook署名を検証
ThunderPhoneが送信するすべてのWebhookおよびツールリクエストには署名が付与されています。ここにある手順で一度署名を検証し、運用するすべてのエンドポイントで同じチェックを再利用してください。
サーバーに送信するすべてのリクエスト(Webhook 配信と
ツールエンドポイント呼び出し)には、X-ThunderPhone-Signature ヘッダー内に
HMAC-SHA256 署名が含まれます。検証を一度正しく実装し、
すべてのハンドラーで同じヘルパーを使用してください。
アルゴリズム
- 生のリクエスト本文(POST された完全に同一のバイト列)を読み取ります。
hmac_sha256(secret, body).hexdigest()を計算します。X-ThunderPhone-Signatureと定数時間で比較します。 (単純な文字列比較ではタイミング情報が漏えいします。)
送信するバイト列そのものに署名するため、生の本文を検証すれば
常に機能します。これらのバイト列は、ペイロードの正規 JSON シリアル化
でもあります。キーはアルファベット順にソートされ、区切り文字はコンパクト
(空白なしの , と :)、エンコーディングは UTF-8 です。フレームワークが
解析済み JSON のみを公開する場合は、同等の別の方法として、正規形式で再シリアル化して
その値に HMAC を計算できます。
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")生の本文を優先してください。手順が 1 つ少なく、一部の言語における JSON 数値の往復変換の特性の影響を受けません。
使用するシークレット
| ソース | シークレット |
|---|---|
Webhook エンドポイント(/v1/developer/webhook-endpoints) | 作成時に一度だけ返されるエンドポイントごとの secret(16 進数 48 文字) |
| レガシー単一 URL Webhook | GET /v1/webhook で返される組織ごとの secret |
ツールエンドポイント呼び出し(endpoint.url への直接呼び出し) | 組織レベルの Webhook シークレット(レガシー単一 URL Webhook と同じもの)。エンドポイントごとのシークレットではありません。 |
シークレットはシークレットマネージャーまたは環境変数に保存し、コミットしないでください。
リファレンス実装
以下の 4 つはいずれも生のリクエスト本文を検証します。
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endフレームワーク別の設定
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})ツール呼び出しの検証
エージェントが
関数ツールを直接呼び出す場合(ツールに
endpointがある場合)、設定済みのendpoint.headersに加えて、
リクエストには次の2つのThunderPhoneヘッダーが含まれます。
X-ThunderPhone-Call-ID— 実行中の通話の数値ID。X-ThunderPhone-Signature— 正確なリクエスト本文のバイト列に対して、組織レベルのWebhookシークレットをキーとして生成されたHMAC-SHA256。
同じverify()ヘルパーを変更せずに使用できますが、次の2点に注意してください。
GET/DELETEツールには本文がありません。 引数はクエリパラメータとして渡され、署名は空のバイト文字列に対して計算されます。したがって、Pythonではverify(b"", sig, secret)、Nodeではverify(Buffer.alloc(0), sig, secret)を使用します。クエリ文字列をハッシュ化しないでください。- レガシーWebhookが設定されていない組織には、組織シークレットがありません。 この場合、ツール呼び出しには
X-ThunderPhone-Call-IDのみが含まれ、署名ヘッダーはありません。署名シークレットを取得するにはレガシーWebhook(PUT /v1/webhook)を設定するか、endpoint.headersを介して独自のヘッダーでツール呼び出しを認証してください。
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...Webhook モードのツールディスパッチ(endpointのないツールで、telephony.tool / web.toolとして組織Webhookに配信されるもの)は、通常の署名付きWebhookです。上記の標準的な手順を適用してください。両方のリクエスト形式については関数ツールを参照してください。
よくある落とし穴
デフォルトの形式で再シリアライズする
本文を解析し、JSON ライブラリのデフォルト設定(, / : の後のスペース、挿入順のキー)で再出力すると、
異なるバイト列が生成され、HMAC が破損します。生の本文を検証してください。再シリアライズが必要な場合は、
キーのソート、コンパクトな区切り文字、UTF-8 という正規形式に完全に一致させてください。
フレームワークが JSON を自動解析する
Express の express.json() ミドルウェアは本文ストリームを消費するため、
生のバイト列が失われます。webhook ルートに限定して express.raw() を使用するか、
前段のミドルウェアで生の本文をバッファリングしてください。
NestJS / Koa でも同様です。「raw body」のドキュメントを確認してください。
タイミングセーフでない比較
JS の expected === signature や Python の expected == signature は、
実行時間が変動する比較です。代わりに crypto.timingSafeEqual
または hmac.compare_digest をそれぞれ使用してください。パフォーマンス差はありません。
ツールエンドポイントに誤ったシークレットを使用する
直接のツールエンドポイント呼び出しは、/v1/developer/webhook-endpoints のエンドポイントごとのシークレットではなく、
組織レベルの webhook シークレット(GET /v1/webhook)で署名されます。
同じ verify() 関数を再利用できますが、ツールルートでは組織のシークレットを渡してください。
GET/DELETE ツールでクエリ文字列をハッシュ化する
本文のないツールメソッドでは、署名の対象は空のバイト文字列です。これにより、 リクエスト本文が何であっても生のリクエスト本文を HMAC にかける、単一の共通手順を維持できます。 URL やクエリ文字列をハッシュ化しても一致することはありません。
不一致時に 401 を返さない
検証に失敗しても 200 を返すと、ハンドラーがリプレイ攻撃の標的になります。 検証に失敗した場合は、必ず 2xx 以外で応答してください。