ブログ一覧

Webhookの二重処理を防ぐには?署名検証・冪等性・再送に強い受信設計

· 約10分で読めます

同じWebhookが2回届く、200を返したのに処理が消える、古い通知で状態が戻る。Stripeを例に、生の本文による署名検証、永続的なinbox、DB制約、条件付き更新、外部APIの冪等キーをつないで解説します。

Webhookが2回届くのはなぜ?応答の消失と再送を前提にする

Webhookは外部サービスがこちらのHTTPエンドポイントへ出来事を通知する仕組みです。送信元は、こちらが処理を終えていても応答を受け取れなければ再送することがあります。手動の再送と自動の再送が重なる場合もあります。Stripeは通知の重複と順序の不保証を説明しているため、「一度だけ、発生順に来る」を前提にしません。

独自の例:処理成功と応答成功は別なので、正常な再送でも二重処理が起こりうる
  1. 送信元: イベントevt-Aを送る
  2. DB: 受信APIが注文を更新し、コミットする
  3. 受信API: 200を返すが、送信元まで届かない
  4. 送信元: 同じevt-Aを再送する
  5. 受信API: 副作用の重複を防いで、受付結果を返す

この記事では、架空の注文サービスがStripeの支払い完了通知を受け、ローカルDBの注文を更新する例を使います。SQLはPostgreSQL 18、処理コードは疑似コードです。送信元ごとのAPIや通知形式は異なり、例をそのまま動くSDKコードとしては扱いません。

署名検証はJSONに変換する前の生の本文で行う

Stripeでは、受信した生のバイト列、Stripe-Signatureヘッダー、エンドポイントの署名シークレットを公式SDKの検証処理へ渡します。先にJSONへ解析してから文字列へ戻すと、空白やエスケープなどが変わり、署名が一致しない原因になります。リクエストサイズも制限し、検証できない通知では業務データを変更しません。

署名付き時刻のある方式では、送信元の仕様に沿って時刻の許容範囲も確認します。GitHubの本文HMACを、Stripeと同じ署名付きタイムスタンプの方式だと決めつけないようにします。HMACを自分で比較する必要がある場合は定数時間の比較を使います。署名検証の失敗を避けるために検証を外す方法は選びません。

200を返すタイミング:未処理イベントを永続化した後

受信APIで重い業務処理まで終えると、応答が遅れて再送が増えやすくなります。ここでは検証したイベントをinboxという受付テーブルへ未処理で保存し、コミットが成功してから200を返します。ワーカーはこのテーブルを定期的に読みます。メモリに置いただけ、タスクを起動しただけの段階では受付を完了しません。

受信APIの疑似コード:検証・永続化・応答の順番
raw = read_body_with_size_limit(request)
event = verify_with_provider_sdk(raw, request.headers, endpoint_secret)
scope = resolve_trusted_source_scope(endpoint_config, event)

transaction:
    insert_pending_event_if_absent(scope, event)
commit

return HTTP_200

verify_with_provider_sdkなどは説明用の関数名です。DBへ保存できなければ成功応答を返さず、再送で回復できる経路を残します。コミットの結果だけ不明になった場合も、同じイベントIDによる再受付を安全に行えるようにします。扱わないイベント種別は、検証後に意図的に無視する方針を定めます。

重複排除は事前SELECTだけでなく、一意制約で守る

2つの受信処理が同時に「まだない」と判断できるため、SELECTだけでは重複を防げません。次の例は、送信元のスコープとイベントIDを主キーにします。source_scopeには、サーバーで確認した送信元・アカウント・実行モードなど、IDの名前空間を表す値を使います。

永続的な受付の最小例:同じキーを2件コミットさせない
CREATE TABLE webhook_inbox (
    source_scope text NOT NULL,
    event_id text NOT NULL,
    event_type text NOT NULL,
    payload jsonb NOT NULL,
    status text NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'done', 'quarantined')),
    received_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (source_scope, event_id)
);

INSERT INTO webhook_inbox
    (source_scope, event_id, event_type, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (source_scope, event_id) DO NOTHING
RETURNING event_id;

INSERTが0件なら、新しい受付行は作られていません。既存行が未処理でもワーカーが再開できる設計なら、再送へ200を返せます。処理済みだと推測して業務処理を捨てるのではなく、未処理状態を残していることが前提です。実際の運用では試行回数、次の試行時刻、最終エラーなども管理します。本文には必要な情報だけを保存し、保持期限と閲覧権限を決めます。

ワーカーの冪等性:注文更新と処理済み記録を同じDBでまとめる

受付の重複排除だけでは、ワーカーの停止・再試行による二重処理は防げません。DB内だけで完結する注文更新なら、未処理イベントの行をロックし、注文の状態更新とイベントのdoneへの変更を同じトランザクションでコミットします。途中で落ちれば両方をロールバックできる範囲です。

支払い待ちの注文だけを更新する条件付きUPDATEの例
UPDATE orders
SET status = 'paid'
WHERE tenant_id = $1
  AND id = $2
  AND provider_payment_id = $3
  AND total_minor = $4
  AND currency = $5
  AND status = 'awaiting_payment'
RETURNING id;

このイベントがawaiting_paymentからpaidへの遷移を許すことを確認し、$4・$5には検証済みの支払い額と通貨を渡します。金額・通貨もUPDATEの条件へ含めることで、事前検証の後に注文内容が変わった場合のすり抜けを防ぎます。PostgreSQLのRead Committedでは、競合する更新を待った後に更新後の行で条件を再評価します。更新行数0件は成功と決めず、同じ決済・金額・通貨で完了済みなのか、不整合なのかを判定します。

DB内で完結するワーカーの疑似コード:失敗をdoneで隠さない
transaction:
    event = lock_one_pending_event_skip_locked()
    if event is absent:
        commit_and_stop()

    order = lock_and_validate_mapped_order(event)
    changed = transition_awaiting_payment_to_paid(order)
    if changed == 0:
        outcome = inspect_current_order_and_payment(order)
        if outcome is a permanent_inconsistency:
            mark_event_quarantined(event)
            commit_and_stop()
        if outcome is not an accepted_duplicate:
            rollback_and_retry_later()
            stop()

    mark_event_done(event)
commit

上の最小例は、inbox行に加えて対応する注文行もロックし、短いDB内の処理を終える方式です。永続的な不整合はquarantinedへの変更を同じトランザクションでコミットし、一時的な失敗はロールバックして再試行します。再試行の待ち時間・回数は別のトランザクションで記録し、バックオフと上限を設けます。ロック中に外部APIを長く待つことは避け、重い処理をロック外へ出すならリースや再確保時の所有権などを追加で設計します。

イベントIDが違っても、業務上の二重実行や順序逆転は起きる

同じ通知の再送と、別イベントが同じ業務結果を表す場合は区別します。イベントIDで防げるのは前者です。発送指示なら「注文ごとに発送操作を1件にする」など、業務の操作IDや制約でも守ります。一方、同じオブジェクトに将来も複数回の正当な更新が起きるなら、オブジェクトIDだけで全イベントを捨ててはいけません。

支払い完了の後に古い通知が来ても、通知の状態を無条件に代入して注文を巻き戻さないようにします。許可する状態遷移を決め、必要に応じて送信元のAPIから現在状態を確認します。イベントの作成時刻だけを完全な順序や一意性の根拠にせず、返金など別の業務遷移も明示的に扱います。

メール・返金など外部の副作用は、ローカルのDBだけでは原子的にならない

外部APIが成功した直後、ローカルのdone保存前に停止すると、再実行で同じ副作用を起こしえます。先にdoneへすれば、今度は呼び出す前の停止で処理を失います。外部に渡す業務操作IDを永続化し、そのAPIが対応する範囲で同じ冪等キーを使って再試行します。結果不明なら相手の操作IDによる照会や照合へつなぎます。

Stripe API v1では同じキーの結果が保存されますが、保持期間や同じキーのパラメーター一致などの条件があります。API v2を含め、別のAPIへ同じ仕様を一般化しません。送信元の通知の再送期間と、こちらが使う外部APIの冪等キーの保持期間も別です。inboxの重複排除記録を消した後の手動再送まで考え、業務側の制約も残します。

停止位置ごとのテスト:再送しても失わず、二重に進めないか

再現する状況期待する結果
不正署名・改ざん本文・別アカウントの通知受付と業務更新を拒否する
同じイベントを同時に2回送る受付行は1件。業務の副作用も重複しない
保存後、200が相手へ届く前に切断する再送が来ても既存の未処理行から進められる
注文更新後、done保存前にDB処理が停止する同一トランザクションが戻り、安全に再試行できる
支払い額・通貨の不一致や、注文内容の競合更新誤ってpaidにせず、隔離状態をコミットするか、一時的な競合なら再試行する
新しい状態の後に古い通知を送る禁止した状態への巻き戻りが起きない
外部API成功後、ローカル保存前に停止する同じ業務キーの再試行・結果照会で照合できる

受信の2xx率だけでは処理完了は分かりません。最古の未処理イベントの経過時間、再試行数、隔離件数、受信から業務完了までの時間を監視します。署名シークレットや不要な個人情報をログへ残さず、イベントIDと業務IDで調査をつなぎます。

よくある疑問:署名・Redisロック・2xxだけで冪等にできる?

署名・再送・DBの保証を確認する一次資料

この記事で深掘りしたTips