> ## 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-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 相同）——不是每端點密鑰 |

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

## 參考實作

以下四種實作皆會驗證原始請求本文：

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