# 署名検証

> Webhook Admin から配信された Webhook の署名を、受信側で検証する方法です。Standard Webhooks の公式ライブラリで検証できます。

Source: https://webhookadmin.com/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` です。エンドポイントの `body_format` が `raw` の場合、ペイロードは `data` そのものです（署名は配信されたボディに対して付与されます）。

## 公式ライブラリ

| 言語 | パッケージ |
| --- | --- |
| 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 分以上ずれた署名を拒否します。

## SDK

メッセージの送信・エンドポイントの管理・受信側での署名検証は、SDK からも実行できます。API キーを省略すると、環境変数 `WEBHOOK_ADMIN_API_KEY` を読み込みます。

| 言語 | インストール |
| --- | --- |
| Go | `go get github.com/shannonllc/webhookadmin-go` |
| PHP | `composer require shannonllc/webhookadmin` |

**Go**

```go
import webhookadmin "github.com/shannonllc/webhookadmin-go"

client, err := webhookadmin.NewClient(os.Getenv("WEBHOOK_ADMIN_API_KEY"))
message, err := client.Messages.Send(ctx, webhookadmin.SendMessageParams{
	Consumer:  "cus_1024",
	EventType: "invoice.paid",
	Payload:   map[string]any{"invoice_id": "inv_88", "amount": 128000},
}, webhookadmin.WithIdempotencyKey("inv_88-paid"))

wh, err := webhookadmin.NewWebhook(os.Getenv("WEBHOOK_SECRET"))
event, err := wh.Verify(body, r.Header)
```

**PHP**

```php
use WebhookAdmin\Client;
use WebhookAdmin\Webhook;

$wha = new Client(getenv('WEBHOOK_ADMIN_API_KEY'));
$message = $wha->messages->send(
    ['consumer' => 'cus_1024', 'event_type' => 'invoice.paid', 'payload' => ['invoice_id' => 'inv_88', 'amount' => 128000]],
    ['idempotency_key' => 'inv_88-paid'],
);

$wh = new Webhook(getenv('WEBHOOK_SECRET'));
$event = $wh->verify(file_get_contents('php://input'), $_SERVER);
```

## 署名の生成方法

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/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 の署名検証へ移行してください。
