驗證 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")建議優先使用原始主體——少一個步驟,且不會受到某些語言中 JSON 數字往返轉換特性的影響。
使用哪個密鑰?
| 來源 | 密鑰 |
|---|---|
Webhook 端點(/v1/developer/webhook-endpoints) | 建立時僅回傳一次的每端點 secret(48 個十六進位字元) |
| 舊版單一 URL Webhook | 在 GET /v1/webhook 回傳的每組織 secret |
工具端點呼叫(直接呼叫你的 endpoint.url) | 組織層級的 Webhook 密鑰(與舊版單一 URL Webhook 使用相同密鑰)——不是每端點密鑰 |
將密鑰儲存在你的密鑰管理工具或環境變數中——切勿提交至版本控制。
參考實作
以下四種實作都會驗證原始請求主體:
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 之外,請求還會帶有兩個 ThunderPhone 標頭:
X-ThunderPhone-Call-ID——進行中通話的數字 ID。X-ThunderPhone-Signature——使用你的 組織層級 Webhook 密鑰作為金鑰,針對完全一致的請求主體位元組計算的 HMAC-SHA256。
相同的 verify() 輔助函式可直接使用,但有兩點差異:
GET/DELETE工具沒有請求主體。 引數會以查詢參數傳遞,而簽章是根據空位元組字串計算,因此請使用verify(b"", sig, secret)(Python)或verify(Buffer.alloc(0), sig, secret)(Node)。請勿雜湊查詢字串。- 未設定舊版 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 也是同樣情況——請查閱其「原始本文」文件。
非時序安全的比較
JS 中的 expected === signature 或 Python 中的 expected == signature 都是執行時間可變的比較方式。請分別使用 crypto.timingSafeEqual 或 hmac.compare_digest。效能差異可忽略不計。
工具端點使用錯誤的密鑰
直接呼叫工具端點時,會使用組織層級的 Webhook 密鑰(GET /v1/webhook)進行簽署——而不是使用 /v1/developer/webhook-endpoints 中任何個別端點的密鑰。重複使用相同的 verify() 函式,但請確認在工具路由中傳入組織密鑰。
對 GET/DELETE 工具的查詢字串進行雜湊
對於沒有本文的工具方法,簽章涵蓋空位元組字串,因此可維持單一通用方式:無論原始請求本文為何,都對其進行 HMAC。對 URL 或查詢字串進行雜湊永遠不會相符。
不在不符時回傳 401
在驗證失敗時回傳 200,會讓處理常式成為重放攻擊的目標。驗證失敗時,務必回應非 2xx 狀態碼。