驗證 webhook 簽署

我們傳送至你伺服器的每個請求——webhook 傳送及工具端點呼叫——均會在 X-ThunderPhone-Signature 標頭中附帶 HMAC-SHA256 簽章。正確完成一次驗證,然後將同一個輔助函式套用至每個處理程式。

演算法

  1. 讀取請求的原始主體——即我們 POST 至你的確切位元組。
  2. 計算 hmac_sha256(secret, body).hexdigest()
  3. 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 webhookGET /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 標頭:

同一個 verify() 輔助函式可原封不動使用,但有兩點注意事項:

  1. GET / DELETE 工具沒有主體。 引數會透過查詢參數傳送,而簽名會根據空位元組字串計算——因此請使用 verify(b"", sig, secret)(Python)或 verify(Buffer.alloc(0), sig, secret)(Node)。請不要雜湊查詢字串。
  2. 未設定舊版 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.timingSafeEqualhmac.compare_digest。效能差異可以忽略不計。

工具端點使用錯誤的密鑰

直接呼叫工具端點時,應使用 組織層級的 Webhook 密鑰GET /v1/webhook)簽署——而非 /v1/developer/webhook-endpoints 中任何個別端點的密鑰。可重用同一個 verify() 函式,但請確保在工具路由中傳入組織密鑰。

為 GET/DELETE 工具雜湊查詢字串

對於沒有請求內容的工具方法,簽章涵蓋空位元組字串,從而保持統一規則:無論原始請求內容是甚麼,一律對其進行 HMAC。對 URL 或查詢字串進行雜湊永遠不會匹配。

不在不匹配時傳回 401

驗證失敗時傳回 200,會令處理程式成為重放攻擊的目標。驗證失敗時,務必回應非 2xx 狀態碼。


下一步