Webhook解説:署名付きペイロードとリトライ

Backend2026-10-29TryQuickToolBox

サードパーティの決済プロバイダーを統合したばかりだとします。ドキュメントには「決済が成功したらWebhookを送信します」と書かれています。簡単ですよね?しかし、次のような疑問が浮かびます。リクエストが本当にそのプロバイダーから来たとどうやって確認するのか?送信時にサーバーがダウンしていたら?同じイベントを2回送信されたら?

Webhookは強力ですが、慎重な取り扱いが必要です。このガイドでは、ペイロードに署名して真正性を検証する方法、失敗を処理するためのリトライの実装、重複処理を避けるための冪等性の確保といった essentials をカバーします。

Webhookとは?

WebhookはHTTPコールバックです。アプリがAPIをポーリングして変更を確認する代わりに、イベントが発生したときにプロバイダーが指定したURLにHTTP POSTを送信します。これにより、統合が効率的かつほぼリアルタイムになります。

一般的な例としては、決済通知(Stripe、PayPal)、CI/CDトリガー(GitHub、GitLab)、メッセージングプラットフォーム(Slack、Discord)などがあります。

なぜ署名でWebhookを保護するのか?

Webhookエンドポイントは公開されています。誰でもPOSTリクエストを送信できます。検証がなければ、攻撃者がイベントを偽造する可能性があります。たとえば、未払いの注文を支払い済みとしてマークするなどです。

共有シークレットでペイロードに署名することで、リクエストが期待される送信者から発信されたこと、およびペイロードが改ざんされていないことを検証できます。

HMAC署名の仕組み

プロバイダーとあなたは秘密鍵を共有します。各Webhookについて、プロバイダーはその秘密鍵を使用してペイロードのHMAC(Hash-based Message Authentication Code)を計算し、署名をヘッダー(例:X-Signature)に含めます。あなたは自分の側でHMACを再計算して比較します。

HMACは完全性と真正性を保証します。なぜなら、秘密鍵を持つ者だけが有効な署名を生成できるからです。

署名付きペイロードの実装

Node.js(Express)とPython(Flask)での典型的な実装を見ていきましょう。原則はどの言語にも当てはまります。

ステップ1:シークレットの取得と保存

Webhookを登録すると、プロバイダーからシークレットが提供されます。環境変数やシークレットマネージャーなど安全な場所に保存し、コードには決して含めないでください。

ステップ2:生のボディをキャプチャする

署名検証には正確な生のボディバイトが必要です。検証前にフレームワークがJSONを解析すると、再シリアル化によってバイトが変わり、検証が失敗する可能性があります。

Expressでは、Webhookルートにexpress.raw({ type: 'application/json' })を使用します。Flaskでは、解析前にrequest.get_data()にアクセスします。

ステップ3:署名の計算と比較

タイミング攻撃を防ぐために、定数時間比較関数を使用してください。

// Node.js (Express)
const crypto = require('crypto');

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-signature'];
  const secret = process.env.WEBHOOK_SECRET;
  const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
  
  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process event
  const event = JSON.parse(req.body);
  // ... handle event
  res.status(200).send('OK');
});

Pythonでは、hmac.compare_digestを使用します:

# Python (Flask)
import hmac, hashlib, os
from flask import request, abort

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Signature')
    secret = os.environ['WEBHOOK_SECRET'].encode()
    expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)
    event = request.get_json()
    # ... handle event
    return 'OK', 200

ステップ4:タイムスタンプの処理(オプションだが推奨)

一部のプロバイダーはリプレイ攻撃を防ぐために署名にタイムスタンプを含めます。たとえば、Stripeのスキームはtimestamp.payloadに署名します。タイムスタンプが古すぎる(例:5分以上前)リクエストは拒否すべきです。

リトライの実装

ネットワークは失敗します。サーバーがダウンしているか、リクエストがタイムアウトする可能性があります。信頼性の高いWebhookシステムは失敗した配信をリトライします。

プロバイダー側

Webhook送信者を構築する場合、指数バックオフでリトライを実装します。一般的なパターン:1分後、5分後、30分後などにリトライし、最大試行回数(例:5回)まで続けます。受信者が重複排除できるように、ペイロードに一意のイベントIDを含めます。

受信者側

受信者として、次のことを行う必要があります:

冪等性:重複イベントの処理

リトライにより重複イベントが発生する可能性があります。ほとんどのプロバイダーが含めるイベントIDを使用して、処理済みイベントを追跡します。処理前に、IDが既に見られたかどうかを確認し、見られた場合はスキップします。

処理済みIDをTTL付きでデータベースに保存するか、ビジネスロジックで冪等キーを使用します。

ローカルでのWebhookテスト

開発中、ローカルサーバーは公開されていません。ngrokやlocaltunnelなどのツールは、マシンへのトンネルとなる公開URLを作成します。あるいは、リクエストをキャプチャしてリプレイできるWebhookテストサービスを使用します。

一般的な落とし穴とベストプラクティス

比較:署名付き vs. 署名なしWebhook

側面署名付き署名なし
セキュリティ高:送信元と完全性を検証低:誰でも偽造可能
実装の手間中程度:HMACの計算と比較最小限
推奨対象すべての本番システム内部の信頼されたネットワークのみ

FAQ

Webhook署名を検証するにはどうすればよいですか?

共有シークレットを使用して生のリクエストボディのHMACを計算し、定数時間関数を使用して署名ヘッダーと比較します。常に解析済みJSONではなく生のボディを使用してください。

Webhookエンドポイントは何を返すべきですか?

受信を確認するために迅速に2xxステータスコードを返します。処理が失敗した場合は5xxを返してリトライをトリガーしますが、エンドポイントが冪等であることを確認して重複を処理します。

Webhookは何回リトライされますか?

プロバイダーによって異なります。ほとんどは数時間から数日間にわたって指数バックオフで数回リトライします。詳細はプロバイダーのドキュメントを確認してください。

Webhook配信のデバッグ時には、サーバーログの検査がしばしば必要です。Nginxを使用している場合、Nginx Log Analyzerがアクセスログを解析し、失敗したリクエストや異常なパターンを特定するのに役立ちます。