---
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` |
| 路徑與查詢字串 | 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-Hant/webhooks/endpoints)（`/v1/developer/webhook-endpoints`） | 建立時僅回傳一次的每端點 `secret`（48 個十六進位字元） |
| [舊版單一 URL Webhook](/api-reference/organizations#legacy-single-url-webhook) | 在 `GET /v1/webhook` 回傳的每組織 `secret` |
| [工具端點呼叫](/zh-Hant/tools/overview)（直接呼叫你的 `endpoint.url`） | **組織層級的 Webhook 密鑰**（與舊版單一 URL Webhook 使用相同密鑰）——不是每端點密鑰 |

將密鑰儲存在你的密鑰管理工具或環境變數中——切勿提交至版本控制。

## 驗證工具呼叫

當智慧體直接呼叫你的
[函式工具](/zh-Hant/tools/overview)（該工具具有
`endpoint`）時，請求除了你設定的 `endpoint.headers` 外，還會包含下列 ThunderPhone 標頭：

- `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` 的工具，會以 `telephony.tool` / `web.tool` 傳送至你的組織 Webhook）屬於一般的已簽署 Webhook——適用上述的標準方式。請參閱
[函式工具](/zh-Hant/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="Proxy 改寫路徑">
    簽署的路徑是我們所請求的路徑：也就是你在 ThunderPhone
    中設定的 URL 路徑。如果反向 Proxy 或 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-Hant/webhooks/overview">
    傳遞語義、重試、來源 IP 位址。
  </Card>
  <Card title="Webhook 端點" icon="plug" href="/zh-Hant/webhooks/endpoints">
    管理多個 URL、輪替密鑰。
  </Card>
  <Card title="函式工具" icon="screwdriver-wrench" href="/zh-Hant/tools/overview">
    兩種工具呼叫路徑及其請求格式。
  </Card>
  <Card title="工具整合" icon="wrench" href="/zh-Hant/guides/build-tool-integration">
    從頭到尾建置完整的工具支援整合。
  </Card>
</CardGroup>
