目次

Webhook は「何かが起きたら、別のシステムに通知する」仕組みだ。 polling(定期的な問い合わせ)に比べてサーバー負荷が低く、リアルタイム性が高い。しかし、適切に設計しないと「通知の重複」「受信失敗」「セキュリティ侵害」といった問題に直面する。

本稿では、Webhook の送信側・受信側の実装パターン、セキュリティ対策、エラーハンドリングを網羅的に解説する。

1. Webhook の基本アーキテクチャ

Webhook はイベント駆動アーキテクチャの一種だ。発信元(プロバイダー)と受信先(コンシューマー)の関係で構成される。

[イベント発生] → [プロバイダー] → [HTTP POST] → [コンシューマー]
     ↓
  データベース更新
  注文確定
  ユーザー作成

polling との比較:

項目pollingWebhook
リアルタイム性低い(間隔依存)高い(即時通知)
サーバー負荷高い(常に問い合わせ)低い(イベント時のみ)
実装コスト
信頼性高(失敗時に再試行容易)中(受信側がダウンすると消失)

Webhook を選ぶべきケース:

  • イベント発生から数秒以内に処理したい
  • polling 間隔を短くするとサーバー負荷が高くなる
  • 外部サービスの変化を検知したい(GitHub、Stripe、Shopify など)

2. Webhook 送信側(プロバイダー)の実装

2.1 イベント検知とキューイング

イベント発生時に同期で Webhook を送信してはいけない。送信処理が失敗した場合、全体がロールバックする可能性があるためだ。

適切なパターン:

[イベント発生] → [DB 更新] → [キューにジョブ投入] → [即座にレスポンス]
                                       ↓
                              [ワーカーが非同期で Webhook 送信]

Node.js + Bull(Redis キュー)の例:

// イベント発生時の処理
await db.orders.create(orderData);
await webhookQueue.add('order.created', {
  orderId: order.id,
  userId: order.userId,
  payload: orderData
});
return res.status(201).json({ status: 'created' });

// ワーカー処理
webhookQueue.process(async (job) => {
  const { userId, payload } = job.data;
  const endpoints = await getWebhookEndpoints(userId);

  for (const endpoint of endpoints) {
    await sendWebhook(endpoint.url, payload, {
      secret: endpoint.secret,
      event: 'order.created'
    });
  }
});

2.2 再送制御

Webhook 送信が失敗した場合、再送ロジックが必須だ。

指数バックオフ(Exponential Backoff):

試行待機時間
1 回目即時
2 回目1 分
3 回目4 分
4 回目15 分
5 回目1 時間

最大試行回数(例:5 回)を超えた場合、その Webhook は「配信失敗」としてマークし、管理者に通知する。

2.3 Webhook ペイロードの設計

一貫したペイロード構造を提供する:

{
  "id": "evt_123456789",
  "type": "order.created",
  "created_at": "2025-11-08T10:30:00Z",
  "data": {
    "order_id": "ord_987654321",
    "total": 5000,
    "items": [...]
  },
  "metadata": {
    "attempt": 1,
    "api_version": "2025-11"
  }
}

含めるべきフィールド:

  • id: イベントの一意識別子(重複検出に使用)
  • type: イベントタイプ(order.created, user.deleted など)
  • created_at: イベント発生時刻(ISO 8601)
  • data: ペイロード本体
  • metadata: 再送試行回数、API バージョンなど

3. Webhook 受信側(コンシューマー)の実装

3.1 エンドポイントの設計

Webhook 受信エンドポイントは以下の要件を満たす必要がある:

  • 幂等性(Idempotency): 同じイベントを複数回受信しても副作用がない
  • 高速レスポンス: 200 OK を 2-3 秒以内に返す
  • 検証ロジック: 送信元を検証する
app.post('/webhooks/payment', async (req, res) => {
  // 1. タイムスタンプ検証(リプレイ攻撃防止)
  const timestamp = req.headers['x-webhook-timestamp'];
  if (Math.abs(Date.now() - new Date(timestamp).getTime()) > 300000) {
    return res.status(400).json({ error: 'timestamp too old' });
  }

  // 2. 署名検証
  const signature = req.headers['x-webhook-signature'];
  const isValid = verifySignature(req.body, signature, process.env.WEBHOOK_SECRET);
  if (!isValid) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  // 3. 重複チェック(イベント ID で管理)
  const eventId = req.body.id;
  const exists = await db.webhookEvents.findOne({ eventId });
  if (exists) {
    return res.status(200).json({ status: 'already processed' });
  }

  // 4. キューに投入(即座にレスポンス)
  await webhookProcessQueue.add(req.body);

  // 5. イベント ID を保存(重複防止)
  await db.webhookEvents.create({ eventId, receivedAt: new Date() });

  return res.status(200).json({ status: 'received' });
});

3.2 幂等性の確保

Webhook は「最低一度」配信される前提で設計する。同じイベントを複数回受信しても問題ないよう、以下の対策を講じる。

イベント ID で管理:

CREATE TABLE webhook_events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(100),
  received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  processed_at TIMESTAMP,
  status VARCHAR(20)
);
// トランザクション内で処理
const client = await pool.getClient();
try {
  await client.query('BEGIN');

  // 重複チェック
  const check = await client.query(
    'SELECT 1 FROM webhook_events WHERE event_id = $1 FOR UPDATE',
    [eventId]
  );

  if (check.rows.length === 0) {
    // 新規イベントとして処理
    await processEvent(event);
    await client.query(
      'INSERT INTO webhook_events (event_id, status) VALUES ($1, $2)',
      [eventId, 'processed']
    );
  }

  await client.query('COMMIT');
} catch (e) {
  await client.query('ROLLBACK');
  throw e;
}

4. Webhook セキュリティ

4.1 署名検証(HMAC)

Webhook の信頼性を確保するため、HMAC(Hash-based Message Authentication Code)署名を使う。

送信側の署名生成:

import crypto from 'crypto';

function generateSignature(payload, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(JSON.stringify(payload));
  return `sha256=${hmac.digest('hex')}`;
}

// ヘッダーに付与
headers['X-Webhook-Signature'] = generateSignature(payload, secret);

受信側の署名検証:

import crypto from 'crypto';

function verifySignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  const providedSignature = signature.replace('sha256=', '');

  // タイミング攻撃防止のため timingSafeEqual を使用
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(providedSignature)
  );
}

4.2 リプレイ攻撃防止

署名が正しくても、同じペイロードを再送信する「リプレイ攻撃」のリスクがある。対策:

  1. タイムスタンプ検証: 5 分以上経過した webhook は拒否
  2. イベント ID の一意性チェック: 既に処理した ID は拒否
  3. nonce の使用: 使い捨ての乱数値を含める
// タイムスタンプ検証
const timestamp = parseInt(req.headers['x-webhook-timestamp']);
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 分

if (Math.abs(now - timestamp) > maxAge) {
  return res.status(400).json({ error: 'timestamp too old' });
}

4.3 IP ホワイトリスト

主要な Webhook プロバイダーは送信元 IP アドレスを公開している。可能であれば、IP ホワイトリストで制限する。

例(GitHub):

  • 140.82.112.0/20
  • 140.82.113.0/24
  • 140.82.114.0/24

nginx の場合:

location /webhooks/github {
    allow 140.82.112.0/20;
    allow 140.82.113.0/24;
    allow 140.82.114.0/24;
    deny all;

    proxy_pass http://backend;
}

5. エラーハンドリングとモニタリング

5.1 エラーレスポンスの設計

ステータスコード意味再送要否
200-299成功不要
400-499クライアントエラー(署名不正、ペイロード不正)不要(修正が必要)
500-599サーバーエラー必要(一時的障害)
タイムアウト応答なし必要

5.2 デッドレターキュー

最大再送回数を超えた Webhook はデッドレターキュー(DLQ)に送る。

webhookQueue.process(async (job, done) => {
  try {
    await sendWebhook(job.data);
    done();
  } catch (error) {
    if (job.attempts >= job.opts.attempts) {
      // 最大試行回数を超えたので DLQ に送る
      await deadLetterQueue.add({
        ...job.data,
        error: error.message,
        failedAt: new Date()
      });
      // 管理者に通知
      await notifyAdmin({
        type: 'webhook_failed',
        eventId: job.data.id,
        endpoint: job.data.endpoint
      });
    }
    done(error);
  }
});

5.3 モニタリング指標

監視すべき指標:

  • 配信成功率: 目標 99% 以上
  • 平均レイテンシ: 送信から 200 OK までの時間
  • 再送率: 全体の何%が再送か(5% 以下が目標)
  • DLQ 蓄積数: 0 が理想、増加トレンドならアラート

6. Webhook テスト戦略

6.1 ローカルテスト

ngrok や Cloudflare Tunnel を使って、ローカル環境に Webhook を受信する。

# ngrok でトンネル作成
ngrok http 3000

# 生成された URL を Webhook エンドポイントに登録
# https://xxxx.ngrok.io/webhooks/test

6.2 検証ツールの活用

  • Stripe CLI: stripe listen --forward-to localhost:3000/webhooks
  • GitHub Webhook Delivery Log: 過去の配信ログを再送信可能
  • Postman/Insomnia: 手動で Webhook ペイロードを構築・送信

6.3 自動化テスト

describe('Webhook Handler', () => {
  it('should process valid webhook', async () => {
    const payload = createTestPayload('order.created');
    const signature = generateSignature(payload, SECRET);

    const response = await request(app)
      .post('/webhooks/order')
      .set('X-Webhook-Signature', signature)
      .send(payload);

    expect(response.status).toBe(200);
    expect(response.body.status).toBe('received');
  });

  it('should reject invalid signature', async () => {
    const payload = createTestPayload('order.created');

    const response = await request(app)
      .post('/webhooks/order')
      .set('X-Webhook-Signature', 'invalid')
      .send(payload);

    expect(response.status).toBe(401);
  });

  it('should handle duplicate events idempotently', async () => {
    const payload = createTestPayload('order.created');
    const signature = generateSignature(payload, SECRET);

    // 1 回目
    await request(app)
      .post('/webhooks/order')
      .set('X-Webhook-Signature', signature)
      .send(payload);

    // 2 回目(同じイベント ID)
    const response2 = await request(app)
      .post('/webhooks/order')
      .set('X-Webhook-Signature', signature)
      .send(payload);

    expect(response2.status).toBe(200);
    expect(response2.body.status).toBe('already processed');
  });
});

まとめ

  • Webhook はイベント駆動アーキテクチャの要。polling より効率的でリアルタイム性が高い
  • 送信側: キューイングで非同期化、指数バックオフで再送制御
  • 受信側: 幂等性を確保、署名検証、重複検出
  • セキュリティ: HMAC 署名、タイムスタンプ検証、IP ホワイトリスト
  • エラーハンドリング: ステータスコードで再送要否を分ける、DLQ で失敗を管理
  • テスト: ngrok でローカル検証、自動化テストで回帰防止

Webhook の適切な設計は、システム間の信頼性を高める。本稿のパターンを実装に活用してほしい。

免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。