ThunderPhone 2.0 正式上线。全程自助,2 美分/分钟起。查看发布公告

Operations

验证 Webhook 签名

ThunderPhone 发送的每个 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 使用相同的密钥)——不是每端点密钥

请将密钥存储在您的密钥管理器或环境变量中——切勿提交到代码仓库。

参考实现

以下四种实现均验证原始请求体:

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

特定框架接入

FastAPI
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}
Express
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);
  },
);
Django
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() 辅助函数无需修改即可使用,但有两点需要注意:

  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、通过您的组织 Webhook 以 telephony.tool / web.tool 形式传递的工具)属于普通的已签名 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 响应。


后续步骤