Webhookとは、あるサービスで出来事(イベント)が起きたときに、そのサービスがあらかじめ登録されたURLへHTTPのPOSTで知らせてくれる仕組みです。 受け取る側から何度も「変化はありますか?」と問い合わせる必要がなく、起きた瞬間に通知を受け取れます。「注文が作成された」「フォームが送信された」「リポジトリにpushされた」といった通知に広く使われています。

仕組みはシンプルですが、実運用では「本当にそのサービスから来たか(署名検証)」「古いリクエストの再利用を防げるか(リプレイ対策)」「失敗したら再送されるか(リトライ)」「同じ通知が2回来ても1回だけ処理できるか(冪等性)」の4点が大切です。この記事では、Node.js 22とTypeScriptで送信側と受信側の両方を実装し、わざと500を返したり応答を遅らせたりして、リトライと重複受信が起きる様子を実行ログで確認しました。GitHub・Stripe・Slackなどの署名方式の違いも、公式ドキュメントに沿って表にまとめています。

Webhookの早見表

項目 内容
向き サービス(送信側)→ あなたのサーバー(受信側)へのHTTP POST
きっかけ 送信側でイベントが起きたとき
受信側が用意するもの 公開されたHTTPSのURL(エンドポイント)
本物かどうかの確認 共有した秘密鍵によるHMAC署名を検証
リプレイ対策 署名に含まれるタイムスタンプが数分以内か確認
失敗したとき 多くのサービスは2xx以外やタイムアウトで再送
重複への備え イベントIDを記録し、2回目は処理しない
応答の目安 すぐに2xxを返し、重い処理は後で(例:GitHubは10秒以内の応答が必要)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Webhookとは?実装までわかる|署名検証・リトライ・冪等性を実行ログで解説」

Webhookの流れ

sequenceDiagram
    participant S as 送信側サービス
    participant R as 受信側 /webhooks
    participant Q as キュー
    participant W as ワーカー
    S->>R: POST(本文+ID・時刻・署名ヘッダー)
    R->>R: 署名とタイムスタンプを検証
    R->>R: イベントIDが処理済みか確認
    R->>Q: イベントを積む
    R-->>S: 202 Accepted(すぐ返す)
    Q->>W: 取り出して処理
    Note over S,R: 2xx以外やタイムアウトなら<br>送信側が間隔をあけて再送

ポーリング・WebSocketとの違い

Webhookは「サーバーからサーバーへ、イベントのたびに1回HTTPで届く」仕組みです。 似た目的の方法と比べると、向いている場面がはっきりします。

方式 通信の向き 接続 即時性 向いている場面
Webhook 送信側 → 受信側(サーバー間) イベントごとに1回のHTTP 高い サービス間の連携、外部サービスからの通知
APIポーリング 受信側 → 送信側 一定間隔でHTTP 間隔しだい 通知の仕組みがない、取りこぼしを定期的に補う
WebSocket 双方向 つなぎっぱなし とても高い チャット、共同編集など画面へのリアルタイム反映
Server-Sent Events サーバー → ブラウザ つなぎっぱなし 高い 進捗表示、通知の一方向配信

Webhookとポーリングは組み合わせて使うのが実務の定番です。 Webhookで素早く受け取り、取りこぼしに備えて定期的にAPIで最新状態を確認する、という形です。

実装の全体像

ここからのコードは、すべて手元(localhostのみ)で実行したものです。

ファイル 役割
signature.ts 署名の作成と検証(送信側・受信側で共有)
receiver.ts 受信側。検証 → 重複確認 → キューへ → すぐ202
sender.ts 送信側。署名付きで送り、失敗したら指数バックオフで再送
attack.ts 不正なリクエストを送って、拒否されるか確かめる

実行環境は Node.js 22.22、TypeScript(tsx 4.23 で実行、tsc --strict の型チェックも通過)、Express 5.2 です。

署名の形式

今回は次の3つのヘッダーを使います。署名する文字列に「イベントID」と「タイムスタンプ」を含めるのがポイントで、どちらかを書きかえると署名が合わなくなります。

ヘッダー 例 役割
X-Webhook-Id evt_001 イベントの一意なID。再送しても同じ値
X-Webhook-Timestamp 1790000000 送信時刻(UNIX秒)。再送ごとに更新
X-Webhook-Signature sha256=5f2c… ID.タイムスタンプ.本文 のHMAC-SHA256

署名の作成と検証(signature.ts)

import crypto from 'node:crypto';

export const SECRET = process.env.WEBHOOK_SECRET ?? 'whsec_demo_only_change_me';
export const TOLERANCE_SEC = 300; // 5分

// 署名対象は「ID.タイムスタンプ.生のボディ」
export function sign(id: string, timestamp: number, rawBody: string, secret = SECRET): string {
  const mac = crypto.createHmac('sha256', secret).update(`${id}.${timestamp}.${rawBody}`).digest('hex');
  return `sha256=${mac}`;
}

export type VerifyResult = { ok: true } | { ok: false; reason: string };

export function verify(
  headers: { id?: string; timestamp?: string; signature?: string },
  rawBody: Buffer,
  now = Math.floor(Date.now() / 1000),
): VerifyResult {
  const { id, timestamp, signature } = headers;
  if (!id || !timestamp || !signature) return { ok: false, reason: 'missing headers' };

  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(now - ts) > TOLERANCE_SEC) {
    return { ok: false, reason: `timestamp out of tolerance (${now - ts}s)` };
  }

  const expected = Buffer.from(sign(id, ts, rawBody.toString('utf8')));
  const received = Buffer.from(signature);
  // 長さが違うと timingSafeEqual は例外を投げるので先に比べる
  if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
    return { ok: false, reason: 'signature mismatch' };
  }
  return { ok: true };
}
  • === ではなく crypto.timingSafeEqual で比べます。 一致した文字数によって処理時間が変わると、そこから正しい署名を推測される余地が生まれるためです。GitHubの公式ドキュメントでも、通常の == を使わず定数時間の比較を使うよう書かれています。
  • 署名は「パースする前の生の本文」で計算します。 JSONとして読み込んでから文字列に戻すと、空白やキーの順番が変わって一致しなくなります。

受信側(receiver.ts)

import express from 'express';
import { verify } from './signature.ts';

type WebhookEvent = { id: string; type: string; created: number; data: Record<string, unknown> };

const app = express();
const seen = new Set<string>();          // 本番では DB の UNIQUE 制約や Redis を使う
const queue: WebhookEvent[] = [];
// 検証用:FAIL_PLAN='{"evt_002":2}' なら evt_002 に2回500を返す
const failPlan = new Map<string, number>(Object.entries(JSON.parse(process.env.FAIL_PLAN ?? '{}')));
// 検証用:SLOW_ONCE=evt_003 なら evt_003 の1回目は応答を3秒遅らせる
const slowOnce = new Set<string>((process.env.SLOW_ONCE ?? '').split(',').filter(Boolean));
const t0 = Date.now();
const log = (msg: string) => console.log(`[recv +${((Date.now() - t0) / 1000).toFixed(1)}s] ${msg}`);

// 署名検証には「パース前の生のボディ」が必要なので express.raw を使う
app.post('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  const id = req.header('x-webhook-id');
  const result = verify(
    { id, timestamp: req.header('x-webhook-timestamp'), signature: req.header('x-webhook-signature') },
    req.body as Buffer,
  );
  if (!result.ok) {
    log(`${id ?? '-'} 401 rejected: ${result.reason}`);
    return res.status(401).send(result.reason);
  }

  const remain = failPlan.get(id!) ?? 0;
  if (remain > 0) {
    failPlan.set(id!, remain - 1);
    log(`${id} 500 (simulated failure, remaining=${remain - 1})`);
    return res.status(500).send('temporary error');
  }

  // 冪等性:同じイベントIDは二度処理しない
  if (seen.has(id!)) {
    log(`${id} 200 duplicate -> skip`);
    return res.status(200).send('duplicate');
  }
  seen.add(id!);
  queue.push(JSON.parse((req.body as Buffer).toString('utf8')) as WebhookEvent);
  log(`${id} enqueued (queue=${queue.length})`);

  if (slowOnce.delete(id!)) {
    log(`${id} slow response... (3s)`);
    await new Promise((r) => setTimeout(r, 3000));
  }
  res.status(202).send('accepted'); // 重い処理はせず、すぐ 2xx を返す
});

// ワーカー:キューから取り出して非同期に処理
setInterval(async () => {
  const ev = queue.shift();
  if (!ev) return;
  await new Promise((r) => setTimeout(r, 300)); // 重い処理の代わり
  log(`${ev.id} processed type=${ev.type}`);
}, 100);

app.listen(7056, () => log('receiver listening on http://localhost:7056/webhooks'));

受信側の順番は「検証 → 重複確認 → キューに積む → すぐ2xx」です。 Expressでは、express.json() をアプリ全体に先に適用すると本文がパース済みになり、署名が合わなくなります。Webhookのルートだけ express.raw() を使うのが確実です。

送信側(sender.ts)

import { sign } from './signature.ts';

const ENDPOINT = 'http://localhost:7056/webhooks';
const MAX_ATTEMPTS = 5;
const TIMEOUT_MS = 2000;
const t0 = Date.now();
const log = (msg: string) => console.log(`[send +${((Date.now() - t0) / 1000).toFixed(1)}s] ${msg}`);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 指数バックオフ+ジッター(デモ用に 0.5s 起点。本番は数秒〜数時間の間隔にする)
const backoff = (attempt: number) => 500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 200);

export async function deliver(event: { id: string; type: string; data: Record<string, unknown> }) {
  const body = JSON.stringify({ ...event, created: Math.floor(Date.now() / 1000) });
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    const ts = Math.floor(Date.now() / 1000); // 再送ごとに署名し直す
    try {
      const res = await fetch(ENDPOINT, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Id': event.id, // 再送しても同じID
          'X-Webhook-Timestamp': String(ts),
          'X-Webhook-Signature': sign(event.id, ts, body),
        },
        body,
        signal: AbortSignal.timeout(TIMEOUT_MS),
      });
      log(`${event.id} attempt ${attempt} -> ${res.status}`);
      if (res.ok) return true;
      if (res.status >= 400 && res.status < 500 && res.status !== 429) return false; // 直らないエラーは再送しない
    } catch (e) {
      log(`${event.id} attempt ${attempt} -> ${(e as Error).name}`);
    }
    if (attempt < MAX_ATTEMPTS) {
      const wait = backoff(attempt);
      log(`${event.id} retry in ${wait}ms`);
      await sleep(wait);
    }
  }
  log(`${event.id} gave up`);
  return false;
}

const events = [
  { id: 'evt_001', type: 'order.created', data: { orderId: 'o-1001' } },
  { id: 'evt_002', type: 'form.submitted', data: { formId: 'contact' } },
  { id: 'evt_003', type: 'order.created', data: { orderId: 'o-1002' } },
];
for (const ev of events) await deliver(ev);

再送のたびに同じイベントIDを使い、タイムスタンプと署名は作り直します。 こうすると、受信側は古いリクエストの再利用をタイムスタンプで拒否しつつ、正規の再送はIDで重複と判断できます。

実行ログ:リトライと重複受信を再現する

受信側を次の設定で起動し、送信側から3件のイベントを送りました。

  • evt_002 には2回続けて500を返す
  • evt_003 は受け付けた後、応答を3秒遅らせる(送信側のタイムアウトは2秒)
FAIL_PLAN='{"evt_002":2}' SLOW_ONCE=evt_003 npx tsx receiver.ts
npx tsx sender.ts

送信側のログ(実際の出力)

[send +0.1s] evt_001 attempt 1 -> 202
[send +0.1s] evt_002 attempt 1 -> 500
[send +0.1s] evt_002 retry in 621ms
[send +0.7s] evt_002 attempt 2 -> 500
[send +0.7s] evt_002 retry in 1128ms
[send +1.8s] evt_002 attempt 3 -> 202
[send +3.8s] evt_003 attempt 1 -> TimeoutError
[send +3.8s] evt_003 retry in 554ms
[send +4.4s] evt_003 attempt 2 -> 200

受信側のログ(実際の出力)

[recv +0.0s] receiver listening on http://localhost:7056/webhooks
[recv +1.9s] evt_001 enqueued (queue=1)
[recv +1.9s] evt_002 500 (simulated failure, remaining=1)
[recv +2.2s] evt_001 processed type=order.created
[recv +2.5s] evt_002 500 (simulated failure, remaining=0)
[recv +3.7s] evt_002 enqueued (queue=1)
[recv +3.7s] evt_003 enqueued (queue=2)
[recv +3.7s] evt_003 slow response... (3s)
[recv +4.0s] evt_002 processed type=form.submitted
[recv +4.1s] evt_003 processed type=order.created
[recv +6.2s] evt_003 200 duplicate -> skip

ログから読み取れることを整理します。

イベント 起きたこと 結果
evt_001 1回で202 1回だけ処理
evt_002 500が2回 → 待ち時間が約0.6秒、約1.1秒と倍々に延びて再送 → 3回目で202 リトライで最終的に1回だけ処理
evt_003 受信側は受け付けて処理まで完了。しかし応答が2秒を超え、送信側は失敗と判断して再送 2回届いたが、IDで重複と判定して処理は1回

evt_003が大切なケースです。 受信側では処理が終わっているのに、送信側からは「失敗」に見えるため再送が起きます。ネットワークの切断やタイムアウトでは避けられない現象なので、Webhookは「少なくとも1回届く(重複はありうる)」ものとして受信側を作る必要があります。また、すぐに2xxを返す設計にしておけば、このようなタイムアウト自体を減らせます。

実行ログ:不正なリクエストを拒否する

attack.ts から、署名やタイムスタンプに問題のあるリクエストを送りました。

const body = JSON.stringify({ id: 'evt_900', type: 'order.created', data: { orderId: 'o-9' } });
const now = Math.floor(Date.now() / 1000);
const old = now - 600;
// 1) 署名ヘッダーなし
// 2) 別の秘密鍵で署名
// 3) 正しく署名した後で本文の orderId を書きかえ
// 4) 10分前のタイムスタンプで正しく署名したリクエスト(再利用を想定)
// 5) 正しい署名

実際の出力

1) 署名ヘッダーなし                  -> 401 missing headers
2) 別の秘密鍵で署名                  -> 401 signature mismatch
3) 署名後にボディを改ざん               -> 401 signature mismatch
4) 10分前の正規リクエスト              -> 401 timestamp out of tolerance (600s)
5) 正しい署名                     -> 202 accepted

本文を1文字変えただけでも署名が合わなくなり、正しく署名された古いリクエストもタイムスタンプで拒否できました。

主要サービスの署名方式の比較

考え方はどれも「共有した秘密鍵でHMAC-SHA256」ですが、署名する文字列とヘッダーの形が違います。 公式SDKやライブラリがある場合は、それを使うのが確実です。

サービス/仕様 署名ヘッダー 署名する文字列 タイムスタンプ 補足
GitHub X-Hub-Signature-256: sha256=<hex> 本文 署名に含まれない 配信IDは X-GitHub-Delivery。失敗した配信は自動では再送されず、手動かAPIで再送
Stripe Stripe-Signature: t=…,v1=… タイムスタンプ.本文 含まれる 公式SDKの constructEvent() で検証。生の本文が必要
Slack X-Slack-Signature: v0=<hex> と X-Slack-Request-Timestamp v0:タイムスタンプ:本文 含まれる 時刻の差が5分を超えたら拒否
Standard Webhooks webhook-id / webhook-timestamp / webhook-signature ID.タイムスタンプ.本文 含まれる 署名は空白区切りで複数並べられ、鍵の切り替えに対応

今回の実装は、Standard Webhooksと同じくIDとタイムスタンプを署名に含める形にしています。GitHubのように署名にタイムスタンプが含まれない方式では、配信ID(X-GitHub-Delivery)を記録して重複を防ぐことが特に大切です。

本番で気をつけること

項目 おすすめ
通信 HTTPSのみで受ける
秘密鍵 環境変数やシークレット管理に置く。ローテーション時は新旧両方で検証できる期間を設ける
重複の記録 メモリではなく、DBのUNIQUE制約やRedisに保存。保存期間はリトライ期間より長く
応答 検証とキュー投入だけ行い、すぐ2xx。送信側のタイムアウト(数秒〜30秒程度が多い)より十分短く
順番 届く順番は保証されない前提で作る。必要ならイベントの時刻や、APIで最新状態を取り直して判断
失敗の扱い 再送が止まったイベントに備え、定期的なAPIでの照合や手動再送の手順を用意
CSRF対策との関係 フレームワークのCSRF検証を外すのはWebhookのパスだけに限定し、代わりに署名検証を必須にする
ログ イベントID・受信時刻・検証結果を残す。秘密鍵や署名そのものは出力しない

送信側を作る場合は、Standard Webhooksの仕様が参考になります。15〜30秒程度のタイムアウト、指数バックオフにランダムな揺らぎ(ジッター)を加えたリトライが推奨されています。

よくある質問

Q. WebhookとAPIの違いは何ですか? A. APIは「こちらから取りに行く」、Webhookは「相手から届く」仕組みです。Webhookも中身はHTTPのリクエストなので、「逆向きのAPI」と説明されることもあります。

Q. ローカルの開発環境でWebhookを受け取るには? A. 外部のサービスからは localhost に届かないため、サービスが提供するCLIの転送機能や、トンネルツールで一時的な公開URLを用意します。この記事のように送信側も自作して、署名・リトライ・重複の動きを手元で確かめておくと、本番の問題の切り分けがしやすくなります。

Q. 冪等性(べきとうせい)とは何ですか? A. 同じ操作を何回行っても、結果が1回行ったときと同じになる性質です。Webhookは再送で同じイベントが届くことがあるため、イベントIDを記録して2回目以降は処理しないようにします。

Q. 受信処理が失敗したときは、何を返せばよいですか? A. 一時的な問題(DBにつながらない等)なら5xxを返して再送してもらいます。署名が合わないなど再送しても直らない場合は4xxを返します。多くの送信側は2xx以外を失敗として扱うため、処理済みの重複には2xxを返すのがポイントです。

まとめ

  • Webhookは、イベントが起きたときに送信側から登録URLへHTTP POSTで届く通知の仕組み
  • 署名は ID.タイムスタンプ.生の本文 のHMAC-SHA256を timingSafeEqual で比較
  • タイムスタンプの許容幅(例:5分)で、正しく署名された古いリクエストも拒否できる
  • 500やタイムアウトでは再送が起き、処理済みでも同じイベントが再び届くことを実行ログで確認
  • イベントIDで重複を判定すれば、2回届いても処理は1回にできる
  • 受信側は検証とキュー投入だけ行ってすぐ2xxを返し、重い処理はワーカーで

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Webhookとは?実装までわかる|署名検証・リトライ・冪等性を実行ログで解説」

株式会社RJCでは、サービス同士をつなぐ仕組みを手を動かして確かめることを、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?