# 署名検証

> Webhook Admin から届いた Webhook の署名を受け側で検証する方法。Standard Webhooks の公式ライブラリで検証できます。

Source: https://webhookadmin.com/ja/docs/sdk/

送信先に届く Webhook には [Standard Webhooks](https://www.standardwebhooks.com/) の形式で署名が付いています。受け側は公式ライブラリで検証できます。

## 届く形

```
POST https://example.com/webhooks
content-type: application/json
webhook-id: msg_2Zq8…
webhook-timestamp: 1790000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
user-agent: Webhook Admin/1

{ "type": "invoice.paid", "timestamp": "2026-09-27T01:32:05.000Z", "data": { "invoice_id": "inv_88", "amount": 128000 } }
```

| ヘッダー | 内容 |
| --- | --- |
| `webhook-id` | メッセージの ID（`msg_…`）。再送でも変わらない |
| `webhook-timestamp` | 送った時刻（Unix 秒） |
| `webhook-signature` | `v1,<base64>`。鍵の切り替え中は空白区切りで 2 つ |

本文は `{ "type", "timestamp", "data" }`。`data` は `POST /v1/messages` の `payload` です。

## 公式ライブラリ

| 言語 | パッケージ |
| --- | --- |
| JavaScript / TypeScript | `npm install standardwebhooks` |
| Python | `pip install standardwebhooks` |
| Go | `go get github.com/standard-webhooks/standard-webhooks/libraries/go` |
| Ruby | `gem install standardwebhooks` |
| Java / Kotlin | `com.standardwebhooks:standardwebhooks`（Maven Central） |
| Rust | `cargo add standardwebhooks` |
| C# | `dotnet add package StandardWebhooks.StandardWebhooks` |
| PHP・Elixir | [github.com/standard-webhooks/standard-webhooks](https://github.com/standard-webhooks/standard-webhooks) |

鍵は送信先を作ったときの `secret`（`whsec_…`）。ライブラリにはそのまま渡します。

**Node.js**

```js
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…
const app = express();

// 署名は届いた本文そのもので検証するので、JSON に変換する前の本文を受け取る
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = wh.verify(req.body, req.headers);
    console.log(event.type, event.data);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});

app.listen(3000);
```

**Python**

```python
import os
from flask import Flask, request
from standardwebhooks import Webhook

wh = Webhook(os.environ["WEBHOOK_SECRET"])  # whsec_…
app = Flask(__name__)

@app.post("/webhooks")
def webhooks():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400
    print(event["type"], event["data"])
    return "", 200
```

公式ライブラリは、時刻が今から 5 分以上ずれた署名を受け付けません。

## 署名の作り方

1. 署名する文字列は `{webhook-id}.{webhook-timestamp}.{本文}`。本文は届いたバイト列のまま使う
2. 鍵は `whsec_` の後ろを base64 で戻したバイト列
3. HMAC-SHA256 の結果を base64 にし、先頭に `v1,` を付ける
4. `webhook-signature` のどれか 1 つと一致すれば本物

**ライブラリを使わない場合（Node.js）**

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, headers, body) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');
  // 鍵の切り替え中は署名が空白区切りで 2 つ以上並ぶ
  return headers['webhook-signature'].split(' ').some((s) => {
    const [version, sig] = s.split(',');
    return version === 'v1' && sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

## 応答と再送

- 2xx を返すと成功。それ以外の応答・15 秒の打ち切り・接続の失敗は再送します。
- 既定では、すぐ・5 秒後・5 分後・30 分後・2 時間後・5 時間後・10 時間後・10 時間後に送ります（最初の送信を含めて最大 8 回、約 28 時間）。送信先ごとに回数と間隔を選べます（[再送の設定](https://webhookadmin.com/ja/docs/api/#retry-policy)）。
- リダイレクトは追いません。
- 同じ `webhook-id` が 2 回以上届くことがあります。処理済みの ID は 2 回目以降を捨てます。
- 失敗が 5 日続いた送信先は自動で止めます。

## 鍵の切り替え

`POST /v1/endpoints/{id}/rotate-secret` か管理画面で新しい鍵に切り替えると、24 時間は古い鍵の署名も付けて送ります。そのあいだに受け側の鍵を差し替えます。

## 互換の署名

独自の署名を検証している受け側を移行するあいだ、送信先ごとに署名のヘッダーを 1 つ追加できます。Standard Webhooks のヘッダーは常に付きます。管理画面・API（`compat_signature`）・MCP で設定し、顧客向けの設定画面では確認だけできます。

| 項目 | 内容 |
| --- | --- |
| `header` | ヘッダーの名前（小文字、64 文字まで）。`webhook-*`・`content-type`・`authorization` など、送信に使うものは使えません |
| `content` | `body` は本文だけ、`timestamp_body` は `{webhook-timestamp}.{本文}` に署名 |
| `encoding` | `hex` か `base64` |
| `prefix` | 任意。値の前に付ける文字（例 `sha256=`）。空白を含まない ASCII、32 文字まで |

- 方式は HMAC-SHA256 です。鍵は署名の鍵の文字列（`whsec_…` 全体）を UTF-8 のまま使います。
- 鍵の切り替え中は、新しい鍵だけで署名します。
- 試しの送信にも付きます。`null` を渡すと外れます。

**GitHub の X-Hub-Signature-256 と同じ形**

```
{ "compat_signature": { "header": "x-hub-signature-256", "content": "body", "encoding": "hex", "prefix": "sha256=" } }

x-hub-signature-256: sha256=<hex>
```

**受け側の検証（Node.js）**

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyCompat(body, header, secret) {
  // 鍵は whsec_… の文字列のまま
  const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```

`body` には時刻が入らないため、**同じ要求をそのまま送り直す攻撃（リプレイ）を防げません**。できるだけ `timestamp_body` を使い、受け側は Standard Webhooks の署名の検証へ移行してください。
