Open in
Webhook署名を検証する
ThunderPhone が送信するすべてのWebhookおよびツールリクエストには、タイムスタンプ付きの署名が付与されます。ここで紹介する手順またはSDKヘルパーを使用して検証し、5分を超えて再送されたリクエストを拒否します。
サーバーに送信するすべてのリクエスト(Webhook 配信、従来の単一 URL Webhook、ツールエンドポイント呼び出し)には、2 つの署名ヘッダーが含まれます。どちらも同じシークレットを使用する HMAC-SHA256 です。
| ヘッダー | 署名対象 | リプレイ攻撃対策 |
|---|---|---|
X-ThunderPhone-Signature-V2(推奨) | タイムスタンプ、メソッド、パス、クエリ、本文 | 5 分を超えて古いリクエストを拒否 |
X-ThunderPhone-Signature | 本文のみ | なし: 取得されたリクエストは永久に検証可能 |
新しい統合では X-ThunderPhone-Signature-V2 を検証し、既存の統合もこれへ移行してください。X-ThunderPhone-Signature は変更されず、引き続きすべてのリクエストで送信されるため、これを基に構築された検証処理は引き続き動作します。送信を停止する前に日程をお知らせします。
署名 V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etは、リクエスト送信時の Unix 時刻(秒単位)です。再試行された 配信は送信時に再度署名され、独自のtを持ちます。v1は、シークレットをキーとして使用した、以下の署名対象文字列の 16進数 HMAC-SHA256 です。
値は、カンマ区切りの key=value ペアのリストです。認識できないキーは
無視し、いずれかの v1 が一致する場合はリクエストを受け入れてください。
将来の変更時に、2つ目の署名を追加する場合があります。
署名対象文字列
4つのフィールドで構成され、最後のフィールド以外の各フィールドの末尾には
改行(\n、バイト 0x0A)が付きます。
<t>\n<METHOD>\n<path and query>\n<body>| フィールド | 値 |
|---|---|
t | ヘッダー内の t の値 |
METHOD | 大文字の HTTP メソッド: POST、GET、PUT、PATCH、または DELETE |
| path and query | HTTP リクエスト行のリクエストターゲット: パスの後に、存在する場合は ? とクエリ文字列を続けます。スキーム、ホスト、フラグメントは含みません。デコードやパラメーターの並べ替えを行わず、受信したとおりに使用してください |
body | 生のリクエスト本文。GET および DELETE ツール呼び出しには本文がないため、署名対象文字列はパスの後の改行で終わります |
検証手順:
- 解析する前に、ヘッダー、メソッド、生のパスとクエリ、および生の本文を読み取ります。
- ヘッダーから
tとすべてのv1を取得します。 tとローカル時刻の差が 300秒 を超える場合は、リクエストを拒否します。- シークレットを使用して、署名対象文字列の16進数 HMAC-SHA256 を計算します。
v1と定数時間で比較します。一致するものがない場合は401を返します。
実例
シークレットは whsec_example_4c1f2a9b です。t = 1767225600
(2026-01-01 00:00:00 UTC)に、次のリクエストを送信します。
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
{"customer_id":"cust_123"}署名対象文字列は次の4行で、本文の後に改行はありません。
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}その HMAC-SHA256 は
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
です。実装をこの値と照合するか、openssl を使用して再現してください。
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'同じ秒に /tools/lookup?customer_id=cust_123 に対して行う GET
ツール呼び出しの本文は空です。署名対象文字列は
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n であり、署名は
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280 です。
SDK を使用する場合
Python および TypeScript SDKにはヘルパーが含まれています
(thunderphone 0.2.0 以降、または @thunderphone/sdk 0.2.0 以降)。
このヘルパーは5分間の許容時間を確認し、定数時間で比較します。また、複数の
シークレットを受け入れるため、ダウンタイムなしでローテーションできます。
from thunderphone import verify_signature
ok = verify_signature(
header, # the X-ThunderPhone-Signature-V2 value
secret=SECRET, # or [NEW_SECRET, OLD_SECRET] while rotating
method=method, # "POST"
path=path, # "/tools/lookup?region=us", exactly as received
body=raw_body, # bytes, before any JSON parsing
tolerance=300, # optional; seconds
)import { verifySignature } from "@thunderphone/sdk";
const ok = verifySignature({
header, // the X-ThunderPhone-Signature-V2 value
secret: SECRET, // or [NEW_SECRET, OLD_SECRET] while rotating
method, // "POST"
path, // "/tools/lookup?region=us", exactly as received
body: rawBody, // Buffer or string, before any JSON parsing
toleranceSeconds: 300, // optional
});SDK を使用しない場合
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
"""path is the path plus ?query exactly as received; body is the raw body."""
timestamp, signatures = None, []
for item in (header or "").split(","):
key, _, value = item.strip().partition("=")
if key == "t" and value.isdigit() and len(value) <= 15:
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
let timestamp;
const signatures = [];
for (const item of (header || "").split(",")) {
const [key, value = ""] = item.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
.update(body)
.digest("hex"),
);
return signatures.some((sig) => {
const candidate = Buffer.from(sig);
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}フレームワークの接続
各例では、生のパスとクエリ、および生のボディを読み取り、SDK ヘルパーを呼び出します。
import json
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
app = FastAPI()
def raw_path_and_query(request: Request) -> str:
path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
query = request.scope.get("query_string", b"").decode("latin-1")
return f"{path}?{query}" if query else path
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
import { verifySignature } from "@thunderphone/sdk";
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 ok = verifySignature({
header: req.header("X-ThunderPhone-Signature-V2"),
secret: process.env.WEBHOOK_SECRET,
method: req.method,
path: req.originalUrl, // path and query as received
body: req.body,
});
if (!ok) 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 HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
@csrf_exempt
@require_POST
def hook(request):
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=request.get_full_path(),
body=request.body, # raw bytes
):
return HttpResponseForbidden("invalid signature")
event = json.loads(request.body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})req.originalUrl(Express)および ASGI の raw_path と query_string
(FastAPI)は、受信したリクエストターゲットそのものです。Django の
get_full_path() と Flask の request.path は先にパスをデコードするため、
通常のパスでは同じ文字列になります。エンドポイントパスに %2F、;、= などの
パーセントエンコード文字または予約文字が含まれる場合は、代わりにサーバーが提供する
生のターゲットを読み取ってください(たとえば Gunicorn では request.META["RAW_URI"])。
使用するシークレット
同じシークレットで両方のヘッダーに署名します。
| ソース | シークレット |
|---|---|
Webhook エンドポイント(/v1/developer/webhook-endpoints) | 作成時に一度だけ返されるエンドポイントごとの secret(16 進数 48 文字) |
| レガシー単一 URL Webhook | GET /v1/webhook で返される組織ごとの secret |
ツールエンドポイント呼び出し(endpoint.url への直接呼び出し) | 組織レベルの Webhook シークレット(レガシー単一 URL Webhook と同じもの)。エンドポイントごとのシークレットではありません。 |
シークレットはシークレットマネージャーまたは環境変数に保存し、コミットしないでください。
ツール呼び出しの検証
エージェントが関数ツールを直接呼び出す場合(ツールに
endpoint がある場合)、リクエストには設定した endpoint.headers に加えて、次の ThunderPhone ヘッダーが含まれます。
X-ThunderPhone-Call-ID— 進行中の通話の数値 ID。X-ThunderPhone-Signature-V2— 組織レベルのウェブフックシークレットをキーとして使用する、前述のタイムスタンプ付き署名。X-ThunderPhone-Signature— 同じキーを使用する、従来の本文のみの署名。
ウェブフックとは次の 2 点が異なります。
GET/DELETEツールには本文がありません。 引数はクエリ パラメータとして渡されます。V2 署名ではパスとクエリを通じてこれらを対象とし、 本文フィールドは空です。生のクエリ文字列で検証してください。パラメータを解析して 新しいクエリ文字列を構築すると、順序やエンコーディングが変わる可能性があります。 従来のヘッダーは空のバイト文字列に署名します。- 従来のウェブフックが設定されていない組織には、組織シークレットがありません。 この
場合、ツール呼び出しには
X-ThunderPhone-Call-IDのみが含まれ、 署名ヘッダーは含まれません。従来のウェブフック (PUT /v1/webhook)を設定して署名シークレットを取得するか、endpoint.headersを介して 独自のヘッダーでツール呼び出しを認証してください。
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=ORG_WEBHOOK_SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
args = json.loads(body) if body else dict(request.query_params)
...Express では、本文パーサーにより GET の req.body は空のオブジェクトになるため、
Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) を渡してください。
Webhook モードのツールディスパッチ(endpointのないツールで、telephony.tool / web.toolとして組織Webhookに配信されるもの)は、通常の署名付きWebhookです。上記の標準的な手順を適用してください。両方のリクエスト形式については関数ツールを参照してください。
レガシー署名: X-ThunderPhone-Signature
元のヘッダーは、同じシークレットを使用して生の本文のみから生成した 16 進数の HMAC-SHA256 です。タイムスタンプを含まないため、署名付きリクエストを 1 つ取得した第三者は再送できます。インテグレーションが V2 へ移行する間も、すべてのリクエストで送信されます。
- 生のリクエスト本文(ThunderPhone が送信した正確なバイト列)を読み取ります。
hmac_sha256(secret, body).hexdigest()を計算します。X-ThunderPhone-Signatureと定数時間で比較します。
ThunderPhone は送信するバイト列そのものに署名します。そのバイト列はペイロードの正規 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 数値のラウンドトリップ特有の問題の影響を受けません。これは V2 の本文フィールドにも当てはまります。
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よくある落とし穴
クロックスキュー
5分間のウィンドウは、サーバーの時計を基準に計測されます。NTP と 同期してください。5分を超えてずれた時計では、すべての リクエストが拒否されます。
プロキシによるパスの書き換え
署名対象のパスは、ThunderPhone で設定した URL のパスです。リバース プロキシまたは API ゲートウェイが、アプリケーションでリクエストを 受信する前にプレフィックスを削除または追加する場合は、検証前に 送信したパスを再構築してください(たとえば、プレフィックスを戻します)。
デフォルト形式での再シリアライズ
ボディを解析し、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 をそれぞれ使用してください。パフォーマンス差はありません。
ツールエンドポイント用のシークレットが誤っている
ツールエンドポイントへの直接呼び出しは、組織レベルのWebhook
シークレット(GET /v1/webhook)で署名されます。/v1/developer/webhook-endpoints
のエンドポイントごとのシークレットは使用しません。同じヘルパーを再利用できますが、
ツールルートでは組織シークレットを渡してください。
GET/DELETE ツールのクエリ文字列
V2 では、送信されたクエリ文字列がそのまま署名されます。レガシーヘッダーは 空のボディに署名し、クエリ文字列には決して署名しないため、レガシーヘッダー用に URL をハッシュ化しても一致しません。
ウィンドウ内の重複
タイムスタンプが防ぐのは5分より古いリプレイであり、そのウィンドウ内の
2回目のコピーではありません。エンドポイント配信は少なくとも1回行われるため、
同じイベントの再試行が届くこともあります。エンベロープの event_id で
重複を排除してください。
不一致時に 401 を返さない
検証に失敗しても 200 を返すと、ハンドラーがリプレイ攻撃の標的になります。 検証に失敗した場合は、必ず 2xx 以外で応答してください。