---
title: "驗證 webhook 簽名"
description: "ThunderPhone 傳送的每個 webhook 及工具請求均已簽署。使用此處的範例驗證一次簽名，然後在你運行的每個端點重複使用相同檢查。"
---

每個傳送至你伺服器的請求——webhook 傳送及工具端點調用——均會在
`X-ThunderPhone-Signature` 標頭中附帶 HMAC-SHA256 簽署。正確實作一次驗證，
然後將同一個輔助函式套用至每個處理常式。

## 演算法

1. 讀取請求的**原始**主體——即我們 POST 至你的確切位元組。
2. 計算 `hmac_sha256(secret, body).hexdigest()`。
3. 與 `X-ThunderPhone-Signature` 以**固定時間**方式比較。
   （一般字串比較會洩露時間資訊。）

我們簽署的正是實際傳送的位元組，因此驗證原始主體
必定有效。這些位元組同時亦是 payload 的**標準 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 數字
來回序列化時出現的細微差異。

## 使用哪個密鑰？

| 來源 | 密鑰 |
|--------|--------|
| [Webhook 端點](/yue/webhooks/endpoints)（`/v1/developer/webhook-endpoints`） | 建立時僅會傳回一次的每端點 `secret`（48 個十六進位字元） |
| [舊版單一 URL webhook](/api-reference/organizations#legacy-single-url-webhook) | 透過 `GET /v1/webhook` 傳回的每組織 `secret` |
| [工具端點調用](/yue/tools/overview)（直接調用你的 `endpoint.url`） | **組織層級 webhook 密鑰**（與舊版單一 URL webhook 使用相同密鑰）——並非每端點密鑰 |

將密鑰儲存於你的密鑰管理工具或環境變數中——切勿提交至程式碼庫。

## 參考實作

以下四種實作均會驗證原始請求主體：

<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>

## 特定框架整合

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

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

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

## 驗證工具呼叫

當智能體直接呼叫你的
[函式工具](/yue/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
@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——適用上述標準做法。請參閱
[函式工具](/yue/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="/yue/webhooks/overview">
    傳送語義、重試、來源 IP 位址。
  </Card>
  <Card title="Webhook 端點" icon="plug" href="/yue/webhooks/endpoints">
    管理多個 URL、輪換密鑰。
  </Card>
  <Card title="函式工具" icon="screwdriver-wrench" href="/yue/tools/overview">
    兩種工具調用路徑及其請求格式。
  </Card>
  <Card title="工具整合" icon="wrench" href="/yue/guides/build-tool-integration">
    端對端建立完整的工具支援整合。
  </Card>
</CardGroup>
