Web●●○○○

Webhook

Webhook ・ うぇぶふっく

イベントが起きたら相手のサービスが HTTP でこちらへ知らせてくれる「逆向きの API」。ポーリングをなくす。

✦ 30秒で試す — 航海数秒おきに「何かあった?」と聞きに行く無駄を、なくせるこのあと、お店の残高はどうなりうる?予想してみる

概要

Webhook は、「何かが起きたら、こちらの URL に HTTP リクエストを送って知らせてください」と相手のサービスに登録しておく仕組みです。ふつうの API 呼び出しは自分から相手に尋ねに行きますが、Webhook では矢印が逆になり、イベントが起きた瞬間に相手側のサーバがこちらのエンドポイント(受け口の URL)を呼び出します。このため「逆向きの API」「HTTP コールバック」とも呼ばれます。

身近な例は至るところにあります。Stripe で決済が完了したら自社のサーバに通知が届く、GitHub に push されたら CI/CD が動き出す、監視のアラートが Slack に投稿される。これらはすべて Webhook です。特別なプロトコルは要らず、受け側は「POST を受け取るふつうの HTTP エンドポイント」を1つ用意するだけなので、サービス間の連携のいちばん手軽な共通語として定着しています。

なぜ生まれたか

Webhook 以前、外部サービスの変化を知る方法は「何度も聞きに行く」ポーリングだけでした。「新しい注文はある?」「まだ?」と数秒〜数分おきに API を叩き続けるのです。ほとんどの問い合わせは「変化なし」の空振りで、送る側にも受ける側にも無駄な負荷がかかります。間隔を延ばせば、決済の反映が数分遅れるといった体験の悪化に直結します。多くの REST API にはレート制限(一定時間あたりの呼び出し回数の上限)があるので、そもそも十分な頻度で聞けないことも珍しくありません。

そこで発想を反転させ、「変化を知りたい側が聞き続ける」のではなく「変化が起きた側がその瞬間に知らせる」ようにしたのが Webhook です。イベントが起きたときだけ通信が発生するので、無駄な往復が消え、遅れもほぼなくなります。2007年ごろにジェフ・リンゼイがこのパターンに Webhook という名前を付け、REST API の普及とともに「API を提供するなら Webhook もセットで」という形で広まりました。

ポーリングWebhook自社サーバ外部サービス何度も問い合わせる大半は「変化なし」の空振り負荷は高いのに反映は遅れる自社サーバの受信URL外部サービス — イベント発生起きた瞬間に1回だけ通知無駄な往復ゼロ・遅れもほぼゼロ
ポーリングと Webhook の対比 — 聞き続けるか、起きたときだけ知らせてもらうか

詳細

基本の流れ

使い方はどのサービスでもほぼ共通です。まず外部サービスの管理画面や API で、通知先の URL と受け取りたいイベントの種類(決済完了、push、issue の作成など)を登録します。以後、該当するイベントが起きるたびに、外部サービスがその URL へ POST リクエストを送ってきます。本文はイベントの内容を表す JSON が一般的で、イベントごとに一意の ID が付いています。受け側は内容を処理し、HTTP の 200 番台を返して「受け取った」と伝えます。この応答が、次に述べる再送の判断材料になります。

署名の検証 — 誰でも叩ける URL を守る

Webhook の受け口は、インターネットに公開された「誰でも POST できる URL」です。何もしなければ、攻撃者が偽の「決済完了」通知を送りつけることもできます。そこで多くのサービスは、送るときに本文と共有の秘密鍵から HMAC というハッシュ化を使った署名を計算し、ヘッダに付けます(Stripe の Stripe-Signature、GitHub の X-Hub-Signature-256 など)。受け側は同じ計算をして一致を確かめることで、正規の送り主であること(なりすましの防止)と、途中で書き換えられていないこと(改ざんの防止)を同時に確認できます。署名にタイムスタンプを含め、古すぎる通知を拒否して、盗み見た通知を後から送り直すリプレイ攻撃を防ぐ設計も一般的です。「Webhook を受けるなら署名の検証は必須」と覚えてください。もちろん受け口は HTTPS にします。

再送と冪等性 — 「少なくとも1回」を前提に設計する

ネットワークや受け側のサーバの不調で、通知が届かないことは避けられません。そこで送信側は、エラーが返ったときや、決められた時間内に応答が来なかったとき(タイムアウト)に、同じ通知を送り直します。送り直す間隔は、1秒・2秒・4秒…と倍に延ばしていくリトライと指数バックオフが定番で、落ちている相手に再送が殺到しないようにしています。サービスによっては数時間〜数日にわたって再送を続け、それでも届かなければ諦めて、管理画面から手で送り直せるようにしています。

ここで重要なのは、この保証が「少なくとも1回(at-least-once)」だということです。受け側で処理は成功したのに、応答が遅れてタイムアウトに間に合わなかった場合、送信側から見ると失敗と区別がつかないので、同じ通知がもう一度届きます。

DB受信サーバ決済サービスDB受信サーバ決済サービス3 秒待っても応答が来ないタイムアウト扱いにして 1 秒後に再送POST 入金通知 evt_42残高に 1000 を足す200 OK が遅れて届くPOST 入金通知 evt_42 もう一度evt_42 は処理済みか確かめる処理済み200 OK 何もしない

つまり受け側は「同じイベントが2回来ても壊れない」ように作る必要があり、これが冪等性です。実務では、通知に含まれるイベント ID を処理済みとして記録しておき、見覚えのある ID なら何もせず 200 を返す、という重複の排除が定石です。ID の記録と残高の更新は同じトランザクションで行い、「足したのに記録していない」という中途半端な状態を作らないようにします。

下の部品で、受信サーバを押して「遅い」「停止」に切り替えながら入金の通知を送ってみてください。再送待ちの列に並ぶ通知と、遅れた応答が引き起こす二重の加算、イベント ID による重複の排除が見えます。

⚡ 体験: 届くまで送り直す通知と、二重の処理
受け側
決済サービス送信側・応答を待つのは 3 秒受信サーバ正常(押して切替)DB(残高)¥0処理済みのイベント ID記録していない再送待ちの列(1 → 2 → 4 → 8 秒)再送を待っている通知はありません入金 +¥1,000
通知したイベント0正しい残高¥0DB の残高¥0
○ 止まっている間はバックオフで再送○ 遅い応答で残高が2回足される○ イベント ID で2回目を弾く
まだ通知していません。送信側から見た結果がここに積まれます。

決済サービスで入金が起きると、あなたの受信サーバへ通知が届きます。まず入金の通知をつかんで受信サーバへ投げ込んでください(タップでおまかせ)。そのあと受信サーバを押して停止や遅いに切り替え、もう一度送ってみてください。

受け側の設計 — すぐ返して、あとで処理する

もうひとつの定石が非同期化です。Webhook の送信側は応答を数秒〜十数秒しか待たないことが多く、受けたその場で重い処理(DB の更新、メールの送信、外部 API の呼び出し)をすると、タイムアウト → 再送 → さらに負荷が増える、という悪循環に陥ります。そこで受信のハンドラでは、署名の検証とイベントのメッセージキューへの投入だけを行ってすぐに 200 を返し、実際の処理はワーカーが非同期で行う構成が勧められます。イベントの集中(決済サービスの障害が直ったあとに、たまった通知が一斉に届く、など)への緩衝材としてもキューが効きます。

もうひとつ、Webhook は順番を保証しないことが多い点にも注意が要ります。再送が絡むと、「支払い完了」より先に「返金」が届くこともあり得ます。通知の中身をそのまま信じて状態を上書きするのではなく、イベントの発生時刻を見て古いものを捨てたり、通知をきっかけに相手の API から最新の状態を取り直したりする設計が安全です。

開発時の工夫と実例

開発中の手元のマシンは外から届かないので、Webhook のテストには ngrok や Cloudflare Tunnel のようなトンネルのツールで一時的な公開 URL を作り、手元のサーバへ転送するのが定番です。Stripe CLI のように、イベントを手元へ転送・再現する専用のツールを用意しているサービスもあります。

実例を挙げると、決済(Stripe・PayPal の入金や失敗の通知)、開発の流れ(GitHub の push を合図に CI/CD を起動)、チャットとの連携(Slack・Discord の Incoming Webhook)、SaaS 同士の自動化(Zapier や IFTTT の裏側)など、今のサービス連携の大半は Webhook で動いています。なお Webhook はサーバ間の通知で、ブラウザへのプッシュには SSE や WebSocket を使う、という住み分けも押さえておくと整理しやすいでしょう。

試問

画面の左は決済サービス(通知の送信側。応答を3秒まで待ち、来なければ1→2→4→8秒と間隔を空けて送り直す)、中央はあなたの受信サーバ、右は残高を持つ DB です。「入金 +¥1,000」の通知をつかんで受信サーバへ投げ込むと送れ、受信サーバを押すと 正常 → 遅い → 停止 と切り替わります。上の欄で、受け側が通知を毎回処理するか、イベント ID で重複を弾くかを選べます。

✦ 命題挑戦中

処理を終えた入金の通知が再送でもう一度届いても、残高を正しいままにし、配送を最後まで終わらせよ

  • 処理済みの通知が、再送でもう一度受信サーバに届く
  • それでも DB の残高が正しい残高と一致する
  • 送信側が 200 を受け取り、再送が止まる(諦めさせない)
⚡ 体験: 届くまで送り直す通知と、二重の処理
受け側
決済サービス送信側・応答を待つのは 3 秒受信サーバ正常(押して切替)DB(残高)¥0処理済みのイベント ID記録していない再送待ちの列(1 → 2 → 4 → 8 秒)再送を待っている通知はありません入金 +¥1,000
通知したイベント0正しい残高¥0DB の残高¥0
○ 止まっている間はバックオフで再送○ 遅い応答で残高が2回足される○ イベント ID で2回目を弾く
まだ通知していません。送信側から見た結果がここに積まれます。

決済サービスで入金が起きると、あなたの受信サーバへ通知が届きます。まず入金の通知をつかんで受信サーバへ投げ込んでください(タップでおまかせ)。そのあと受信サーバを押して停止や遅いに切り替え、もう一度送ってみてください。