
Webhook 受信側のベストプラクティス - 署名検証・リトライ・冪等性・順序
「Webhook を受けるだけ」と思って雑に実装すると、偽イベントで在庫が動く・二重課金・リトライ暴発といった事故に直結します。Webhook は受信側の作法が9割です。この記事では、安全で堅牢な受信エンドポイントの作り方を、Stripe・GitHub・Standard Webhooks を一次ソースに整理します。
Webhook とは
送信側(プロバイダ)が、イベント発生時に受信側のエンドポイントへ HTTP POST でプッシュ通知する仕組みです。受信側がAPIを繰り返し叩くポーリングが不要になります(決済イベント、GitHubのpush/PRなどが典型)。設計の責任は受信側に多く乗ります。
1. 署名検証(最重要)
検証しないと、誰でも偽のイベントを送れます。送信元の真正性と改ざん検知のため、HMAC-SHA256 による署名を検証します。
- 共有シークレットで
HMAC-SHA256(secret, signed_payload)を計算し、ヘッダの署名と一致するか確認(例:Stripe-Signature/X-Hub-Signature-256) - リプレイ攻撃対策: 署名対象にタイムスタンプを含め、現在時刻から一定(例: 5分)以上ずれていたら拒否
- 必ず「生のボディ(raw bytes)」で検証する。JSONをパース→再シリアライズするとキー順や空白が変わって署名が一致しない
- 比較は 定数時間比較(
hmac.compare_digest等)。==はタイミング攻撃に弱い
import hmac, hashlib, time
def verify(raw_body: bytes, sig_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts, received = parts.get("t", ""), parts.get("v1", "")
# リプレイ対策: 5分ウィンドウ
if abs(time.time() - int(ts)) > 300:
return False
signed = f"{ts}.".encode() + raw_body # timestamp + "." + raw body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received) # 定数時間比較NOTE
Standard Webhooks という共通仕様もあり、webhook-id / webhook-timestamp / webhook-signature の3ヘッダで HMAC(や Ed25519)署名を標準化しています。OpenAI・Anthropic・Twilio など多数が採用。プロバイダ独自方式でも考え方は同じです。
2. リトライと冪等性
ほとんどの Webhook は at-least-once(少なくとも1回)配信です。「同じイベントが複数回来る」前提で設計します(exactly-once は分散システムの原理上、実現できません)。
- 送信側は失敗時に指数バックオフでリトライ(Stripe は本番で最大3日間など)
- 受信側は冪等に処理する。イベントIDを一意キーにして処理済みを記録し、重複はスキップ
- DBのユニーク制約 +
ON CONFLICT DO NOTHINGなら、競合状態下の重複も防げる
冪等性そのものの考え方はREST API設計と冪等性も参照してください。
3. 配信順序は保証されない
イベントの到着順は保証されません。 Stripe も「subscription.created より先に subscription.deleted が届くことがある」と明言しています。
- 順序に依存しない設計にする
- 必要ならイベントのタイムスタンプ/シーケンスで並べ替える
- 足りない情報はプロバイダのAPIを叩いて補完する(Webほどの payload を信じすぎない)
4. 受信エンドポイントの作法
ここが堅牢さの肝です。まず素早く 2xx を返し、重い処理は非同期に逃がします。
1. 生のボディ(bytes)を取得(JSONパースより前に)
2. 署名検証(失敗なら 400)
3. JSON をパース
4. 冪等チェック(event_id が処理済みなら 200 を返して終了)
5. すぐに 200 OK を返す
6. 非同期キュー(SQS / Celery / BullMQ 等)へ積む
7. ワーカーがビジネスロジックを実行- 即時 2xx: GitHub は10秒以内に 2xx を要求。重い処理を同期実行するとタイムアウト→リトライ暴発
- 生ペイロードを保存(デバッグ・再処理・監査に効く)
- 未知のイベント種別は無視して 2xxを返す(将来の新イベントで壊れない)。500 を返すと延々リトライされる
5. セキュリティ補足
- HTTPS 必須(本番でHTTP不可)
- シークレットのローテーション: 新旧を一定期間併存させて無停止で切替(Stripeは最大24時間)
- IP許可リストは多層防御の一層。署名検証の代わりにはならない
- URLの秘匿性に頼らない(推測されない前提で設計しない)
- フレームワークの CSRF保護からWebhookエンドポイントを除外(弾かれて全部403になる事故が定番)
トークン認証やレート制限と合わせるならJWT・レート制限、ステータスの返し方はHTTPステータスコードも参照。
よくある落とし穴
- パース後のボディで署名検証(再シリアライズで不一致)→ 生バイト列で検証
- 同期処理でタイムアウト → まず 2xx、処理は非同期
- 重複未対応(at-least-onceを忘れる)→ イベントIDで冪等化
- 順序を前提にした処理 → 順序非依存に
==で署名比較 → 定数時間比較- タイムスタンプ検証を省略 → リプレイ可能になる
- 未知イベントで500 → 無視して2xx
まとめ
- 署名検証は必須: HMAC-SHA256+生ボディ+タイムスタンプ(リプレイ対策)+定数時間比較
- at-least-once 前提で冪等に。イベントIDで重複排除(ユニーク制約)
- 配信順序は保証されない。順序非依存にし、足りなければAPIで補完
- 受信は即時 2xx →非同期処理。生ペイロード保存、未知イベントは2xx
- HTTPS・シークレットローテーション・CSRF除外を忘れない
Webhook は「受け取って終わり」ではなく、偽物・重複・順不同・タイムアウトを捌けて初めて実戦投入できます。検証 → 冪等 → 即時2xx → 非同期——この型を守れば、ほとんどの事故は防げます。


