webhook-architecture について
コマースシステムで webhook を確実に受信・送信するためのガイドです。Shopify や Stripe のイベントを扱う開発者や独自のイベントシステムを構築する開発者向けに、HMAC 検証、冪等処理、再試行、デッドレターキュー、Outbox Pattern を解説します。
主な機能
- 01HMAC 署名の検証
- 02イベント ID による冪等処理
- 03指数バックオフによる再試行
- 04デッドレターイベントの保存と再送
- 05Outbox Pattern による確実なイベント送信
活用シーン
- 01Shopify や Stripe のイベントを受信・処理
- 02webhook の欠落や重複処理を調査
- 03外部システムへ業務イベントを確実に送信
- 04単一イベントを複数のコンシューマーへ配信
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill webhook-architectureインストール後、Claude Code で/webhook-architectureを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
tile.jsonevals/outbox-pattern-sender-with-retry-and-dlq/criteria.jsontask.mdevals/secure-idempotent-webhook-receiver/criteria.jsontask.mdevals/webhook-subscription-management-api/criteria.jsontask.md詳細
Webhook アーキテクチャ
概要
Webhook は、コマースプラットフォーム(Shopify、Stripe)がリアルタイムのイベント通知をアプリケーションにプッシュするための HTTP コールバックです。信頼性の高い Webhook インフラストラクチャには、偽装されたイベントを防ぐ HMAC 署名検証、重複配信に対応する冪等なハンドラー、指数バックオフによる再試行ロジック、すべての再試行を使い果たしたイベントを受け取るデッドレターキューが必要です。このスキルでは、信頼性の高い Webhook レシーバーの構築方法と、独自のプラットフォーム向けに Outbox パターンを使用した Webhook 送信方法について説明します。
このスキルを使う場面
- Shopify、Stripe、Square、その他のプラットフォームから Webhook を受信するとき
- Webhook の配信問題によるイベントの取りこぼしや重複処理をデバッグするとき
- 外部システムにイベントを通知する必要があるコマースプラットフォームやアプリを構築するとき
- コマースのマイクロサービス間のイベント駆動型アーキテクチャを設計するとき
- Webhook ファンアウト(1 つのイベントを複数のコンシューマーに配信する仕組み)を設定するとき
基本的な手順
ステップ 1:プラットフォームと処理する Webhook を決定する
| プラットフォーム | Webhook の設定場所 | 購読する特に重要なトピック |
|---|---|---|
| Shopify | 設定 → 通知 → Webhook(または Admin API 経由) | orders/create、orders/paid、orders/cancelled、inventory_levels/update、refunds/create |
| WooCommerce | WP Webhooks プラグイン(無料、wordpress.org)をインストールするか、WooCommerce → 設定 → 高度な設定 → Webhook にある WooCommerce 組み込みの Webhook を使用 | 注文ステータスの変更(処理中、完了、返金済み)、在庫の更新 |
| BigCommerce | 高度な設定 → レガシー API 設定 → Webhook または API 経由 | store/order/statusUpdated、store/product/inventory/updated、store/cart/abandoned |
| カスタム/ヘッドレス | 独自の Webhook システムを構築 | 送信には Outbox パターンを使用し、受信には HMAC 検証と冪等性を使用します。実装方法は以下を参照してください。 |
ステップ 2:プラットフォーム固有の Webhook 設定
Shopify
Shopify 管理画面から Webhook を登録します:
- 設定 → 通知 に移動し、Webhook までスクロールします
- Webhook を作成 をクリックします
- イベントトピック(例:
注文の作成)を選択し、エンドポイント URL を入力します - 形式として JSON を選択します
HMAC 検証用の Webhook シークレットを取得します:
シークレットは Webhook の作成時に表示されます。環境変数として保存してください。Shopify はこのシークレットを使用して、各 Webhook に HMAC-SHA256 署名を付与します。
Admin API を使用するアプリの場合、プログラムから Webhook を登録します:
const res = await fetch(`https://${shopDomain}/admin/api/2025-01/webhooks.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook: {
topic: 'orders/create',
address: `${process.env.APP_URL}/api/webhooks/shopify/order-created`,
format: 'json',
}}),
});
WooCommerce
WooCommerce 組み込みの Webhook を使用します:
- WooCommerce → 設定 → 高度な設定 → Webhook に移動します
- Webhook を追加 をクリックします
- 名前、ステータス:有効、トピック(例:注文の作成)、および 配信 URL を設定します
- シークレット フィールドで、配信ごとに HMAC-SHA256 署名が生成されます。エンドポイントでの検証に使うため、コピーしておきます
または、より細かく制御するには WP Webhooks プラグインを使用します:
- 高度なトリガー条件やペイロードのカスタマイズに対応する WP Webhooks(無料、wordpress.org)をインストールします
- 設定 → WP Webhooks に移動し、WooCommerce の注文イベントに対するトリガーを設定します
- WP Webhooks は、再試行ロジックと配信ログを標準でサポートしています
カスタム/ヘッドレス
カスタムのストアフロントでは、信頼性の高い受信(Stripe、Shopify などからの Webhook の受信)と、信頼性の高い送信(独自の連携先への通知に Outbox パターンを使用)の両方を実装します。
HMAC 署名検証(Shopify および汎用):
// lib/webhooks/verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyShopifyWebhook(rawBody: Buffer, hmacHeader: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(hmacHeader);
const expectedBuffer = Buffer.from(expected);
if (received.length !== expectedBuffer.length) return false;
return timingSafeEqual(received, expectedBuffer);
}
export function verifyStripeWebhook(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.replace('t=', '');
const v1 = parts.find(p => p.startsWith('v1='))?.replace('v1=', '');
if (!timestamp || !v1) return false;
// Reject events older than 5 minutes (replay attack protection)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex');
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
冪等な Webhook レシーバー — プラットフォームのイベント ID を使って重複を排除します:
// app/api/webhooks/shopify/route.ts
export async function POST(req: NextRequest) {
const rawBody = Buffer.from(await req.arrayBuffer());
const hmac = req.headers.get('x-shopify-hmac-sha256') ?? '';
const topic = req.headers.get('x-shopify-topic') ?? '';
const eventId = req.headers.get('x-shopify-webhook-id') ?? '';
// 1. Verify signature — reject invalid requests immediately
if (!verifyShopifyWebhook(rawBody, hmac, process.env.SHOPIFY_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// 2. Idempotency check — deduplicate by event ID
const alreadyProcessed = await db.processedWebhooks.exists(eventId);
if (alreadyProcessed) return NextResponse.json({ received: true, status: 'already_processed' });
// 3. Mark as received BEFORE processing (prevents duplicate on concurrent delivery)
await db.processedWebhooks.insert({ id: eventId, topic, receivedAt: new Date(), status: 'processing' });
// 4. Return 200 immediately, process asynchronously
processWebhookAsync(topic, rawBody, eventId); // Don't await — return fast
return NextResponse.json({ received: true });
}
async function processWebhookAsync(topic: string, rawBody: Buffer, eventId: string) {
try {
const payload = JSON.parse(rawBody.toString('utf8'));
switch (topic) {
case 'orders/create': await importOrder(payload); break;
case 'orders/cancelled': await cancelOrder(payload.id); break;
case 'inventory_levels/update': await syncInventory(payload); break;
}
await db.processedWebhooks.update(eventId, { status: 'processed', processedAt: new Date() });
} catch (err: any) {
await db.processedWebhooks.update(eventId, { status: 'failed', error: err.message });
}
}
信頼性の高い Webhook 送信のための Outbox パターン — 送信側がクラッシュしても、少なくとも 1 回の配信を保証します:
// lib/webhooks/outbox.ts
// Write to outbox in the SAME transaction as the business event
export async function publishEvent(trx: Transaction, eventType: string, payload: object) {
await trx.webhookOutbox.insert({
id: crypto.randomUUID(),
eventType,
payload: JSON.stringify(payload),
status: 'pending',
attempts: 0,
nextRetryAt: new Date(),
});
}
// Outbox poller — runs every 10 seconds, separate from your main app
export async function processOutbox() {
const pending = await db.webhookOutbox.findPending({ status: ['pending', 'retrying'], nextRetryAt: { $lte: new Date() }, limit: 100 });
for (const event of pending) await deliverEvent(event);
}
// Retry schedule: 1min, 5min, 30min, 2hr, 8hr → DLQ after 5 attempts
const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 28_800_000];
async function handleDeliveryFailure(event: OutboxEvent, error: string) {
const nextAttempts = event.attempts + 1;
if (nextAttempts >= RETRY_DELAYS_MS.length) {
await db.webhookOutbox.update(event.id, { status: 'dead_letter', lastError: error });
await db.webhookDeadLetters.insert({ eventId: event.id, failedAt: new Date(), reason: error });
await alertOpsTeam(`Webhook permanently failed after ${nextAttempts} attempts`, { eventType: event.eventType, error });
} else {
const nextRetryAt = new Date(Date.now() + RETRY_DELAYS_MS[nextAttempts - 1]);
await db.webhookOutbox.update(event.id, { status: 'retrying', attempts: nextAttempts, nextRetryAt, lastError: error });
}
}
デッドレターイベントを再生する(サブスクライバーのエンドポイントを修正した後):
export async function replayDeadLetter(deadLetterId: string) {
const deadLetter = await db.webhookDeadLetters.findById(deadLetterId);
await db.webhookOutbox.update(deadLetter.eventId, {
status: 'pending', attempts: 0, nextRetryAt: new Date(),
});
await db.webhookDeadLetters.update(deadLetterId, { replayedAt: new Date() });
}
ベストプラクティス
- 常に直ちに 2xx を返します — Webhook ハンドラーの処理が遅いと配信が滞り、送信元がタイムアウトして再試行する可能性があります。イベントを受信時にキューへ登録し、非同期で処理してください
- 確実な送信には Outbox パターンを使用します — ドメインイベントと同じ DB トランザクション内で outbox テーブルに書き込むと、Webhook 送信側がクラッシュしても少なくとも 1 回の配信が保証されます
- ハンドラーを冪等にします — イベント固有の ID を使って重複を排除してください。「少なくとも 1 回」の配信はすべての Webhook プラットフォームで標準です。同じイベントを 2 回受信しても処理できるようにする必要があります
- 配信の試行をすべて記録します — 応答コード、所要時間、エラーとともに配信の試行を保存してください。これはデバッグに不可欠であり、コンプライアンスの監査証跡にもなります
- アラート機能付きのデッドレターキューを実装します — 再試行回数を使い切ったイベントには人の対応が必要です。Slack/PagerDuty 経由でアラートを送り、再実行できる仕組みを用意してください
よくある落とし穴
| 問題 | 解決策 |
|---|---|
| Webhook の再試行による注文処理の重複 | processed_webhooks テーブルで Webhook イベント ID を一意キーとして使用し、TTL を 30 日に設定して冪等性を実装します |
| Webhook ハンドラーがタイムアウトし、再試行が発生する | Webhook を非同期で処理します。受信時にキューへ書き込み、直ちに 200 を返し、キューから処理します |
| WooCommerce の Webhook 配信に失敗する | 配信エラーについて WooCommerce → System Status → Logs を確認します。よくある原因は、SSL 証明書の問題と、低速な共有ホスティングでのタイムアウトです |
| Shopify の Webhook HMAC が一致しない | 生のリクエスト本文に対して HMAC を計算します。最初に JSON を解析してはいけません。本文パーサーが JSON を再フォーマットし、署名を変更する可能性があります |
| デッドレターが気付かれないまま蓄積する | デッドレター数がしきい値(例:10 件)を超えたらアラートを送ります。デッドレターは、調査が必要なサブスクライバーの体系的な障害を示しています |
関連スキル
- @analytics-integration
- @erp-integration
- @marketplace-connectors
- @monitoring-alerting-commerce