---
title: "Webhook署名を検証する"
description: "ThunderPhone が送信するすべてのWebhookおよびツールリクエストには、タイムスタンプ付きの署名が付与されます。ここで紹介する手順またはSDKヘルパーを使用して検証し、5分を超えて再送されたリクエストを拒否します。"
---

サーバーに送信するすべてのリクエスト（Webhook 配信、従来の単一 URL Webhook、ツールエンドポイント呼び出し）には、2 つの署名ヘッダーが含まれます。どちらも同じシークレットを使用する 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` は、シークレットをキーとして使用した、以下の署名対象文字列の
  16進数 HMAC-SHA256 です。

値は、カンマ区切りの `key=value` ペアのリストです。認識できないキーは
無視し、いずれかの `v1` が一致する場合はリクエストを受け入れてください。
将来の変更時に、2つ目の署名を追加する場合があります。

### 署名対象文字列

4つのフィールドで構成され、最後のフィールド以外の各フィールドの末尾には
改行（`\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. シークレットを使用して、署名対象文字列の16進数 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"}
```

署名対象文字列は次の4行で、本文の後に改行はありません。

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

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

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

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

- `X-ThunderPhone-Call-ID` — 進行中の通話の数値 ID。
- `X-ThunderPhone-Signature-V2` — **組織レベルのウェブフックシークレット**をキーとして使用する、前述のタイムスタンプ付き署名。
- `X-ThunderPhone-Signature` — 同じキーを使用する、従来の本文のみの署名。

ウェブフックとは次の 2 点が異なります。

1. **`GET` / `DELETE` ツールには本文がありません。** 引数はクエリ
   パラメータとして渡されます。V2 署名ではパスとクエリを通じてこれらを対象とし、
   本文フィールドは空です。生のクエリ文字列で検証してください。パラメータを解析して
   新しいクエリ文字列を構築すると、順序やエンコーディングが変わる可能性があります。
   従来のヘッダーは空のバイト文字列に署名します。
2. **従来のウェブフックが設定されていない組織には、組織シークレットがありません。** この
   場合、ツール呼び出しには `X-ThunderPhone-Call-ID` のみが含まれ、
   署名ヘッダーは含まれません。従来のウェブフック
   （`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です。上記の標準的な手順を適用してください。両方のリクエスト形式については[関数ツール](/ja/tools/overview)を参照してください。

## レガシー署名: `X-ThunderPhone-Signature`

元のヘッダーは、同じシークレットを使用して生の本文のみから生成した 16 進数の HMAC-SHA256 です。タイムスタンプを含まないため、署名付きリクエストを 1 つ取得した第三者は再送できます。インテグレーションが V2 へ移行する間も、すべてのリクエストで送信されます。

1. **生の**リクエスト本文（ThunderPhone が送信した正確なバイト列）を読み取ります。
2. `hmac_sha256(secret, body).hexdigest()` を計算します。
3. `X-ThunderPhone-Signature` と**定数時間**で比較します。

ThunderPhone は送信するバイト列そのものに署名します。そのバイト列はペイロードの**正規 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")
```

生の本文を優先してください。手順が 1 つ少なく、一部の言語における 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 でも同様です。「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`
    のエンドポイントごとのシークレットは使用しません。同じヘルパーを再利用できますが、
    ツールルートでは組織シークレットを渡してください。
  </Accordion>

  <Accordion title="GET/DELETE ツールのクエリ文字列">
    V2 では、送信されたクエリ文字列がそのまま署名されます。レガシーヘッダーは
    空のボディに署名し、クエリ文字列には決して署名しないため、レガシーヘッダー用に
    URL をハッシュ化しても一致しません。
  </Accordion>

  <Accordion title="ウィンドウ内の重複">
    タイムスタンプが防ぐのは5分より古いリプレイであり、そのウィンドウ内の
    2回目のコピーではありません。エンドポイント配信は少なくとも1回行われるため、
    同じイベントの再試行が届くこともあります。エンベロープの `event_id` で
    重複を排除してください。
  </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="関数ツール" icon="screwdriver-wrench" href="/ja/tools/overview">
    2 種類のツール呼び出しパスとリクエスト形式。
  </Card>
  <Card title="ツール連携" icon="wrench" href="/ja/guides/build-tool-integration">
    ツールを活用した完全な連携をエンドツーエンドで構築します。
  </Card>
</CardGroup>
