> ## 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")
```

生の本文を推奨します。手順が 1 つ少なく、一部の言語における JSON 数値の
ラウンドトリップの問題の影響を受けません。

## 使用するシークレット

| ソース                                                                          | シークレット                                                                      |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [Webhook エンドポイント](/ja/webhooks/endpoints)（`/v1/developer/webhook-endpoints`） | 作成時に一度だけ返されるエンドポイントごとの `secret`（48 桁の 16 進文字）                               |
| [レガシー単一 URL webhook](/api-reference/organizations#legacy-single-url-webhook) | `GET /v1/webhook` で返される組織ごとの `secret`                                       |
| [ツールエンドポイント呼び出し](/ja/tools/overview)（`endpoint.url` への直接呼び出し）                | **組織レベルの webhook シークレット**（レガシー単一 URL webhook と同じもの）。エンドポイントごとのシークレットではありません |

シークレットはシークレットマネージャーまたは環境変数に保存し、コミットしないでください。

## リファレンス実装

以下の 4 つはいずれも生のリクエスト本文を検証します。

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

## ツール呼び出しの検証

エージェントが
[関数ツール](/ja/tools/overview)を直接呼び出す場合（ツールに
`endpoint` がある場合）、設定した `endpoint.headers` に加えて、
リクエストには次の 2 つの ThunderPhone ヘッダーが含まれます。

* `X-ThunderPhone-Call-ID` — 進行中の通話の数値 ID。
* `X-ThunderPhone-Signature` — リクエスト本文の完全一致するバイト列に対して
  **組織レベルのWebhookシークレット**をキーに使用した HMAC-SHA256。

同じ `verify()` ヘルパーを変更せずに使用できますが、次の 2 点に注意してください。

1. **`GET` / `DELETE` ツールには本文がありません。** 引数はクエリ
   パラメーターとして渡され、署名は**空のバイト文字列**に対して計算されます。
   したがって、Python では `verify(b"", sig, secret)`、Node では
   `verify(Buffer.alloc(0), sig, secret)` を使用します。クエリ文字列をハッシュ化
   しないでください。
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です。上記の標準手順を適用してください。両方のリクエスト形式については
[関数ツール](/ja/tools/overview)を参照してください。

## よくある落とし穴

<AccordionGroup>
  <Accordion title="デフォルト形式で再シリアライズする">
    ボディを解析し、JSON ライブラリのデフォルト設定
    （`,` / `:` の後のスペース、挿入順のキー）で再出力すると、
    バイト列が変わり、HMAC が破損します。生のボディを検証してください。再シリアライズが必要な場合は、
    キーのソート、コンパクトな区切り文字、UTF-8 という正規形式に完全に一致させてください。
  </Accordion>

  <Accordion title="フレームワークが JSON を自動解析する">
    Express の `express.json()` ミドルウェアはボディストリームを消費するため、
    生のバイト列が失われます。Webhook ルートには特に `express.raw()` を使用するか、
    前処理ミドルウェアで生のボディをバッファリングしてください。
    NestJS / Koa でも同様です。「raw body」のドキュメントを確認してください。
  </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="/ja/webhooks/overview">
    配信セマンティクス、再試行、送信元 IP。
  </Card>

  <Card title="Webhook エンドポイント" icon="plug" href="/ja/webhooks/endpoints">
    複数の URL を管理し、シークレットをローテーションします。
  </Card>

  <Card title="Function Tools" icon="screwdriver-wrench" href="/ja/tools/overview">
    2 つのツール呼び出しパスと、それぞれのリクエスト形式。
  </Card>

  <Card title="ツール連携" icon="wrench" href="/ja/guides/build-tool-integration">
    ツールを活用する完全な連携をエンドツーエンドで構築します。
  </Card>
</CardGroup>
