> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thunderphone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 验证 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。

```python theme={null}
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

优先使用原始正文——这样少一步，并且不会受到某些语言中 JSON 数字往返转换细节的影响。

## 使用哪个密钥？

| 来源                                                                        | 密钥                                                    |
| ------------------------------------------------------------------------- | ----------------------------------------------------- |
| [Webhook 端点](/zh/webhooks/endpoints)（`/v1/developer/webhook-endpoints`）   | 创建时仅返回一次的每端点 `secret`（48 个十六进制字符）                     |
| [旧版单 URL webhook](/api-reference/organizations#legacy-single-url-webhook) | 通过 `GET /v1/webhook` 返回的每组织 `secret`                  |
| [工具端点调用](/zh/tools/overview)（直接调用您的 `endpoint.url`）                       | **组织级 webhook 密钥**（与旧版单 URL webhook 使用的密钥相同）——不是每端点密钥 |

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

## 参考实现

以下四种实现均验证原始请求正文：

<CodeGroup>
  ```python Python theme={null}
  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 "")
  ```

  ```javascript Node.js theme={null}
  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 Go theme={null}
  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 Ruby theme={null}
  require "openssl"

  def verify(body, signature, secret)
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
    Rack::Utils.secure_compare(expected, signature.to_s)
  end
  ```
</CodeGroup>

## 特定框架接入

<CodeGroup>
  ```python FastAPI theme={null}
  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}
  ```

  ```javascript Express theme={null}
  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);
    },
  );
  ```

  ```python Django theme={null}
  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})
  ```
</CodeGroup>

## 验证工具调用

当智能体直接调用您的某个
[函数工具](/zh/tools/overview)（该工具具有
`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` 中您自己的请求头对工具调用进行身份验证。

```python theme={null}
@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——适用上述标准方法。有关两种请求形式，请参阅[函数工具](/zh/tools/overview)。

## 常见陷阱

<AccordionGroup>
  <Accordion title="使用默认格式重新序列化">
    解析请求体后，再使用 JSON 库的默认设置重新导出（在 `,` / `:` 后添加空格、按插入顺序排列键）会生成不同的字节，从而导致 HMAC 验证失败。请验证原始请求体——或者如果必须重新序列化，请严格匹配我们的规范格式：键排序、紧凑分隔符、UTF-8。
  </Accordion>

  <Accordion title="框架自动解析 JSON">
    Express 的 `express.json()` 中间件会消耗请求体流，导致您丢失原始字节。请仅在 webhook 路由上使用 `express.raw()`，或者在前置中间件中缓冲原始请求体。NestJS / Koa 也是如此——请查阅它们关于“原始请求体”的文档。
  </Accordion>

  <Accordion title="非时序安全的比较">
    JS 中的 `expected === signature` 或 Python 中的 `expected == signature` 都是时序可变比较。请分别使用 `crypto.timingSafeEqual` 或 `hmac.compare_digest`。性能差异可以忽略不计。
  </Accordion>

  <Accordion title="工具端点使用了错误的密钥">
    直接调用工具端点时，签名使用的是**组织级 webhook 密钥**（`GET /v1/webhook`）——而不是 `/v1/developer/webhook-endpoints` 中任何单个端点的密钥。复用相同的 `verify()` 函数，但请确保在工具路由中传入组织密钥。
  </Accordion>

  <Accordion title="在 GET/DELETE 工具中对查询字符串进行哈希">
    对于没有请求体的工具方法，签名覆盖空字节字符串，从而保持一套通用规则：对原始请求体进行 HMAC，无论其内容是什么。对 URL 或查询字符串进行哈希永远不会匹配。
  </Accordion>

  <Accordion title="不在不匹配时返回 401">
    验证失败时返回 200 会使处理程序成为重放攻击目标。如果验证失败，始终返回非 2xx 响应。
  </Accordion>
</AccordionGroup>

***

## 后续步骤

<CardGroup cols={2}>
  <Card title="Webhook 概览" icon="bolt" href="/zh/webhooks/overview">
    投递语义、重试、源 IP 地址。
  </Card>

  <Card title="Webhook 端点" icon="plug" href="/zh/webhooks/endpoints">
    管理多个 URL、轮换密钥。
  </Card>

  <Card title="函数工具" icon="screwdriver-wrench" href="/zh/tools/overview">
    两种工具调用路径及其请求格式。
  </Card>

  <Card title="工具集成" icon="wrench" href="/zh/guides/build-tool-integration">
    端到端构建完整的工具支持型集成。
  </Card>
</CardGroup>
