Open in
驗證 webhook 簽章
ThunderPhone 傳送的每個 webhook 與工具請求都會以時間戳記簽署。使用此處的範例或 SDK 輔助函式進行驗證,並拒絕超過五分鐘的重放請求。
我們傳送至你伺服器的每個請求(webhook 傳遞、舊版單一 URL webhook 及工具端點呼叫)都會附帶兩個簽章標頭。兩者皆使用相同密鑰的 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是以下簽署字串的十六進位 HMAC-SHA256,使用你的密鑰作為金鑰。
此值是以逗號分隔的 key=value 配對清單。忽略無法辨識的金鑰;只要任一 v1 相符,即可接受該請求:我們可能會在未來變更時新增第二個簽章。
簽署字串
共四個欄位,除最後一個欄位外,每個欄位後方皆接一個換行字元(\n,位元組 0x0A):
<t>\n<METHOD>\n<path and query>\n<body>| 欄位 | 值 |
|---|---|
t | 標頭中的 t 值 |
METHOD | 大寫的 HTTP 方法:POST、GET、PUT、PATCH 或 DELETE |
| 路徑與查詢字串 | HTTP 請求行中的請求目標:先是路徑,若有查詢字串則接著是 ? 和查詢字串。不包含配置、主機或片段。請完全依照收到的內容使用,不要解碼或重新排序參數 |
body | 原始請求主體。GET 與 DELETE 工具呼叫沒有主體,因此其簽署字串會在路徑後的換行字元結束 |
驗證方式:
- 在進行任何剖析前,讀取標頭、方法、原始路徑與查詢字串,以及原始主體。
- 從標頭取得
t與所有v1。 - 若
t比你的時鐘早或晚超過 300 秒,拒絕該請求。 - 使用你的密鑰計算簽署字串的十六進位 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"}簽署字串為以下四行,主體後方沒有換行字元:
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(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——以上帶有時間戳記的簽章,使用你的組織層級 webhook 密鑰。X-ThunderPhone-Signature——舊版僅針對主體的簽章,使用相同密鑰。
與 webhook 有兩項差異:
GET/DELETE工具沒有請求主體。 引數會以查詢 參數傳遞。V2 簽章會透過路徑和查詢涵蓋這些引數, 而主體欄位為空。請使用原始查詢字串進行驗證:剖析 參數後重新建立查詢字串,可能會變更其順序 或編碼。舊版標頭會對空位元組字串簽章。- 未設定舊版 webhook 的組織不會有組織密鑰。 在
此情況下,工具呼叫只會包含
X-ThunderPhone-Call-ID,不會有 簽章標頭。設定舊版 webhook (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
原始標頭僅包含原始主體的十六進位 HMAC-SHA256,並使用相同的密鑰。它不含時間戳記,因此任何擷取到已簽署請求的人都能重放該請求。在整合遷移至 V2 期間,每個請求仍會傳送此標頭。
- 讀取原始請求主體——我們傳送給你的確切位元組。
- 計算
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")建議使用原始主體——這樣可少一個步驟,且不受某些語言中 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 分鐘時,會拒絕所有 請求。
Proxy 改寫路徑
簽署的路徑是我們所請求的路徑:也就是你在 ThunderPhone 中設定的 URL 路徑。如果反向 Proxy 或 API 閘道在你的應用程式看到請求前移除 或加入前綴,請在驗證前重建我們傳送的路徑 (例如,將前綴加回去)。
使用預設格式重新序列化
剖析請求本文後,再以 JSON 函式庫的
預設設定重新輸出(在 ,/: 後加入空格、依插入順序排列鍵)會產生
不同的位元組,並導致 HMAC 失效。請驗證原始請求本文——或者若你
必須重新序列化,請完全符合我們的規範形式:排序
鍵、緊湊分隔符號、UTF-8。
框架自動剖析 JSON
Express 的 express.json() 中介軟體會讀取本文串流,讓你失去原始位元組。請針對 Webhook 路由使用 express.raw(),或在前置中介軟體中緩衝原始本文。NestJS / Koa 也是同樣情況——請查閱其「原始本文」文件。
非時序安全的比較
JS 中的 expected === signature 或 Python 中的 expected == signature 都是執行時間可變的比較方式。請分別使用 crypto.timingSafeEqual 或 hmac.compare_digest。效能差異可忽略不計。
工具端點使用錯誤的密鑰
直接呼叫工具端點時,會使用 組織層級 Webhook
密鑰(GET /v1/webhook)進行簽署——不會使用
/v1/developer/webhook-endpoints 中任何個別端點的密鑰。重複使用相同的輔助程式,
但請確認在工具路由上提供的是組織密鑰。
GET/DELETE 工具上的查詢字串
V2 會完全依照傳送時的查詢字串進行簽署。舊版標頭會簽署 空白請求本文,且從不簽署查詢字串,因此為 舊版標頭雜湊 URL 永遠不會相符。
時間窗口內的重複請求
時間戳記可阻擋超過 5 分鐘的重放攻擊,但無法阻擋該窗口
內的第二份相同請求。端點傳遞採至少一次語意,因此相同事件的
重試也可能送達;請依封套中的
event_id 去除重複項目。
不在不符時回傳 401
在驗證失敗時回傳 200,會讓處理常式成為重放攻擊的目標。驗證失敗時,務必回應非 2xx 狀態碼。