---
title: "验证 Webhook 签名"
description: "ThunderPhone 发送的每个 Webhook 和工具请求都会使用时间戳签名。请使用此处的示例或 SDK 辅助方法进行验证，并拒绝超过五分钟的重放请求。"
---

我们发送到您服务器的每个请求（webhook 投递、旧版单 URL webhook 以及工具端点调用）都包含两个签名标头。两者均使用相同密钥的 HMAC-SHA256。

| 标头 | 签名内容 | 重放保护 |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2`（推荐） | 时间戳、方法、路径、查询参数和请求体 | 拒绝超过 5 分钟的请求 |
| `X-ThunderPhone-Signature` | 仅请求体 | 无：被捕获的请求可永久通过验证 |

请在新的集成中验证 `X-ThunderPhone-Signature-V2`，并将现有集成迁移至该版本。`X-ThunderPhone-Signature` 仍会在每个请求中保持不变地发送，因此基于它构建的验证器可继续正常工作。我们将在停止发送它之前公布日期。

## 签名 V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` 是我们发送请求时的 Unix 时间，单位为秒。重试投递会在发送时重新签名，并使用其自身的 `t`。
- `v1` 是使用您的密钥作为密钥，对下方签名字符串计算得到的十六进制 HMAC-SHA256。

该值是以逗号分隔的 `key=value` 对列表。请忽略您不识别的键；如果任意 `v1` 匹配，则接受该请求：我们可能会在未来的变更中添加第二个签名。

### 签名字符串

共四个字段，每个字段后均跟一个换行符（`\n`，字节 `0x0A`），最后一个字段除外：

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| 字段 | 值 |
|-------|-------|
| `t` | 请求头中的 `t` 值 |
| `METHOD` | 大写 HTTP 方法：`POST`、`GET`、`PUT`、`PATCH` 或 `DELETE` |
| path and query | HTTP 请求行中的请求目标：先是路径，若存在查询字符串，则接 `?` 和查询字符串。不包含协议、主机或片段。请完全按照收到的内容使用，不要解码或重新排序参数 |
| `body` | 原始请求正文。`GET` 和 `DELETE` 工具调用没有正文，因此其签名字符串在路径后的换行符处结束 |

验证步骤：

1. 在进行任何解析之前，读取请求头、方法、原始路径和查询字符串，以及原始正文。
2. 从请求头中获取 `t` 和所有 `v1`。
3. 如果 `t` 比您的时钟早或晚超过 **300 秒**，则拒绝该请求。
4. 使用您的密钥计算签名字符串的十六进制 HMAC-SHA256。
5. 使用恒定时间与 `v1` 比较。如果没有任何匹配项，则响应 `401`。

### 示例

密钥为 `whsec_example_4c1f2a9b`。我们在
`t = 1767225600`（2026-01-01 00:00:00 UTC）发送此请求：

```http
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"}
```

签名字符串为以下四行，正文后没有换行符：

```text
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}
```

其 HMAC-SHA256 为
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`。
请据此检查您的实现，或使用 `openssl` 复现：

```bash
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](/api-reference/sdks) 包含一个辅助函数
（`thunderphone` 0.2.0 或更高版本，`@thunderphone/sdk` 0.2.0 或更高版本）。它会检查 5 分钟时间窗口，使用恒定时间进行比较，并接受密钥列表，以便您无需停机即可轮换密钥。

<CodeGroup>
```python Python
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
)
```

```javascript Node.js
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
});
```
</CodeGroup>

### 不使用 SDK

<CodeGroup>
```python Python
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)
```

```javascript Node.js
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)
    );
  });
}
```
</CodeGroup>

### 框架接入

每个示例都会读取原始路径和查询字符串以及原始请求体，然后调用
SDK 帮助方法。

<CodeGroup>
```python FastAPI
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}
```

```javascript Express
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);
  },
);
```

```python Django
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})
```
</CodeGroup>

`req.originalUrl`（Express）以及 ASGI 的 `raw_path` 和 `query_string`
（FastAPI）就是收到的原始目标。Django 的
`get_full_path()` 和 Flask 的 `request.path` 会先解码路径，对于普通路径，
这会得到相同的字符串。如果您的端点路径包含百分号编码或保留字符，例如 `%2F`、`;` 或 `=`，请改为读取服务器提供的原始目标（例如在
Gunicorn 中使用 `request.META["RAW_URI"]`）。

## 使用哪个密钥？

两个请求头均使用同一个密钥进行签名。

| 来源 | 密钥 |
|--------|--------|
| [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 使用相同的密钥）——不是每端点密钥 |

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

## 验证工具调用

当智能体直接调用您的
[函数工具](/zh/tools/overview)（该工具具有
`endpoint`）时，请求会携带以下 ThunderPhone 标头以及
您配置的 `endpoint.headers`：

- `X-ThunderPhone-Call-ID` ——实时通话的数字 ID。
- `X-ThunderPhone-Signature-V2` ——使用您的**组织级 Webhook 密钥**
  生成的上述带时间戳签名。
- `X-ThunderPhone-Signature` ——旧版仅对请求正文签名，使用相同的密钥。

与 Webhook 有两点不同：

1. **`GET` / `DELETE` 工具没有请求正文**。参数通过查询
   参数传递。V2 签名通过路径和查询字符串覆盖这些参数，
   正文字段为空。请使用原始查询字符串进行验证：解析
   参数并构建新的查询字符串可能会改变其顺序
   或编码。旧版标头对空字节字符串进行签名。
2. **未配置旧版 Webhook 的组织没有组织密钥**。在
   这种情况下，工具调用仅携带 `X-ThunderPhone-Call-ID`，不包含
   签名标头。请配置旧版 Webhook
   （`PUT /v1/webhook`）以获取签名密钥，或通过 `endpoint.headers`
   使用您自己的标头对工具调用进行认证。

```python
@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`、通过您的组织 Webhook
以 `telephony.tool` / `web.tool` 形式传递的工具）属于普通的已签名 Webhook——
适用上述标准方法。有关两种请求形式，请参阅
[函数工具](/zh/tools/overview)。

## 旧版签名：`X-ThunderPhone-Signature`

原始请求头仅包含原始请求正文的十六进制 HMAC-SHA256，并使用相同的密钥。它不带时间戳，因此任何捕获到一条已签名请求的人都可以重放该请求。在集成迁移到 V2 期间，每个请求仍会发送此请求头。

1. 读取**原始**请求正文——即我们发送给您的确切字节。
2. 计算 `hmac_sha256(secret, body).hexdigest()`。
3. 与 `X-ThunderPhone-Signature` 进行**恒定时间**比较。

我们签名的字节与实际传输的字节完全一致。这些字节也是载荷的**规范 JSON 序列化**——键按字母顺序排序、使用紧凑分隔符（`,` 和 `:`，不含空格）、UTF-8 编码。如果您的框架仅提供已解析的 JSON，请按规范重新序列化并对此计算 HMAC：

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

优先使用原始请求正文——这样可减少一个步骤，并避免某些语言中 JSON 数字往返转换的特殊问题。V2 的正文字段同样如此。

<CodeGroup>
```python 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 "")
```

```javascript 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 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 Ruby
require "openssl"

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

## 常见陷阱

<AccordionGroup>
  <Accordion title="时钟偏差">
    5 分钟窗口依据您服务器的时钟计算。请与 NTP 保持同步；时钟偏差超过
    5 分钟会导致每个请求都被拒绝。
  </Accordion>

  <Accordion title="代理重写路径">
    签名路径是我们请求的路径：您在 ThunderPhone 中配置的 URL
    路径。如果反向代理或 API 网关在您的应用看到请求之前移除
    或添加了前缀，请在验证前重建我们发送的路径（例如，将前缀加回去）。
  </Accordion>

  <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` 中任一端点专属密钥。请复用同一辅助函数，
    但请确保在工具路由中传入组织密钥。
  </Accordion>

  <Accordion title="GET/DELETE 工具上的查询字符串">
    V2 会按照发送时的原样对查询字符串签名。旧版请求头会对
    空请求正文签名，绝不会对查询字符串签名，因此为旧版请求头对 URL 进行哈希永远不会匹配。
  </Accordion>

  <Accordion title="窗口内的重复请求">
    时间戳可阻止超过 5 分钟的重放攻击，但不能阻止该窗口内的第二份副本。
    端点投递采用至少一次语义，因此同一事件的重试也可能到达；请根据
    信封中的 `event_id` 去重。
  </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>
