※本記事には広告(楽天アフィリエイト)リンクが含まれる場合があります。
「Stripeの支払い通知が来ない」「GitHubのWebhookが突然401を返す」……
個人開発で決済やCI/CD、Slack連携を組み込むと、必ず一度はWebhookと向き合うことになります。Webhookは便利な反面、シグネチャ検証を忘れると誰でも偽のリクエストを送れてしまい、リトライや重複処理を考えないとデータが二重登録される、といった落とし穴だらけです。
本記事では、Webhookの基本概念からシグネチャ検証・べき等性・リトライ・ローカル開発までを、実際のコード付きで完全解説します。Hono + Cloudflare Workersでの実装例を中心に、Stripe・GitHub・Slackの実例も交えて、本番でも壊れないWebhookを作る方法を学びましょう。
1. Webhookとは何か?APIとの違い
Webhookは「サーバーからサーバーへのHTTPコールバック」です。通常のAPIが「こちらから問い合わせる(ポーリング)」のに対して、Webhookは「イベントが発生したら向こうから教えてくれる(プッシュ)」仕組みです。
| 項目 | 通常のAPI(ポーリング) | Webhook(プッシュ) |
|---|---|---|
| 通信の起点 | 自社サーバーが定期的に問い合わせる | 相手サーバーがイベント発生時に送信 |
| リアルタイム性 | ⚠️ ポーリング間隔ぶん遅延する | ✅ ほぼ即時 |
| サーバー負荷 | 無駄なリクエストが多い | イベント時のみで効率的 |
| 主な用途 | 一覧取得・検索・データ参照 | 決済通知・デプロイ通知・チャット連携 |
| 信頼性設計 | 自分でリトライ制御できる | 受信側で検証・リトライ設計が必要 |
Stripeの「支払い完了」通知、GitHubの「プッシュ/PR作成」通知、Slackの「メンション」通知など、外部サービスとの連携でWebhookは欠かせません。逆に言うと、Webhookを受ける側の実装品質が、そのままサービスの信頼性に直結します。
2. なぜシグネチャ検証が必須なのか
Webhookエンドポイントは誰からでもHTTPリクエストを送れる公開URLです。検証なしで受け取ったデータを信用すると、攻撃者が偽のイベントを送りつけて在庫操作・権限昇格・データ改ざんなどを行えます。
そこで各サービスは、シークレットキー(署名鍵)を使ったHMACシグネチャ検証を提供しています。送信側はボディとシークレットからHMAC-SHA256を計算して署名を付け、受信側は自分で同じ計算をして一致するか確認します。
2.1 Stripeの署名検証(例)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyStripeSignature(
payload: string,
sigHeader: string,
secret: string
): boolean {
// 例: t=1700000000,v1=abc123def...
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => {
const [k, v] = p.split('=');
return [k, v];
})
);
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${payload}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
timingSafeEqualを使うのがポイントです。単純な === 比較はタイミング攻撃のリスクがあるため、定数時間比較が推奨されます。
3. べき等性(Idempotency)の設計
Webhookは同じイベントが複数回届くことが当たり前です。ネットワーク起因の再送や、受信側が500を返したときの自動リトライで重複します。そのため、「同じイベントを2回処理しても問題ない」状態にするべき等性が必須です。
3.1 イベントIDで重複を弾く
// Hono + Cloudflare Workers + D1 の例
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { db, webhookEvents } from '../db';
export async function markProcessed(eventId: string): Promise<boolean> {
// INSERT OR IGNORE で既に処理済みなら何もしない
const result = await db
.insert(webhookEvents)
.values({ eventId, processedAt: new Date().toISOString() })
.onConflictDoNothing()
.returning({ id: webhookEvents.id });
return result.length > 0; // true = 初回処理, false = 既に処理済み
}
イベントIDをユニーク制約付きテーブルにINSERTし、既に存在すればスキップします。これでどの順番・回数で届いても、処理は1回だけ実行されます。
4. Hono + Cloudflare WorkersでのWebhook実装例
ここからは、実際にコードを書いていきます。Hono + Cloudflare Workersは、サーバーレスで低コスト・高可用なWebhookエンドポイントを最速で立てられる構成です。RAGガイドやHonoの記事と合わせて読むと理解が深まります。
4.1 パッケージのインストール
npm create cloudflare@latest webhook-app -- --template hono
cd webhook-app
npm install @hono/zod-validator zod
4.2 エンドポイント実装
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const app = new Hono();
// ペイロードのスキーマ検証
const stripeSchema = z.object({
id: z.string(),
type: z.enum(['checkout.session.completed', 'invoice.paid', 'charge.refunded']),
created: z.number(),
data: z.object({ object: z.record(z.any()) }),
});
app.post(
'/webhooks/stripe',
zValidator('json', stripeSchema),
async (c) => {
const body = await c.req.text(); // 生ボディを署名検証用に再取得
// ...シグネチャ検証(前述のverifyStripeSignature)...
const event = c.req.valid('json');
// べき等チェック
const isNew = await markProcessed(event.id);
if (!isNew) return c.json({ received: true, duplicate: true }, 200);
// イベント種別ごとの処理
switch (event.type) {
case 'checkout.session.completed':
await activateUser(event.data.object.customer_email);
break;
case 'invoice.paid':
await extendSubscription(event.data.object.customer);
break;
}
return c.json({ received: true }, 200);
}
);
export default app;
ポイントは3つです。
- スキーマ検証(zod)で不正なボディを早期に弾く
- 署名検証で送信元を確認する
- べき等チェックで重複処理を防ぐ
5. リトライと失敗処理のベストプラクティス
Webhook受信側がエラー(5xx)を返すと、送信側は指数バックオフで自動リトライします。Stripeは数日間にわたって再送し、GitHubは30日間再送します。つまり受信側は「一時的に失敗しても、あとで同じペイロードで再送される」ことを前提に作る必要があります。
5.1 成功時は200を返す
処理が完了したら必ず200を返しましょう。2xx以外(特に5xx)を返すと再送の対象になります。「受信したら200、処理は非同期キューに投げる」という設計も有効です。
5.2 キューで処理を分離する
// 受信は高速に200を返し、重い処理はキューへ
app.post('/webhooks/stripe', async (c) => {
// 検証...
await queue.send('process-stripe-event', {
id: event.id,
type: event.type,
payload: event.data.object,
});
return c.json({ received: true }, 200);
});
Cloudflare QueuesやUpstash QStash、Inngestなどを使えば、受信と処理を分離して可用性を高められます。Webhook受信が遅いと送信側がタイムアウトして再送されるため、「受信は早く、処理はあとで」が鉄則です。
5.3 失敗イベントの監視
何度も失敗するイベントを放置すると、ユーザーの決済が反映されないままになります。エラートラッキング(Sentry等)に接続し、リトライ上限を超えたイベントを検知する仕組みを入れておきましょう。
6. ローカル開発:ngrok / Cloudflare Tunnelで安全に試す
Webhookは「外部サービスがあなたのURLに向かって送る」ため、ローカル開発ではトンネリングツールが必須です。自分のマシンに公開URLを割り当てて、外部サービスからローカルの開発サーバーへ届けるようにします。
6.1 Cloudflare Tunnel(無料・おすすめ)
# ローカルサーバーを 3000 番で起動しておく
npx wrangler tunnel run --url http://localhost:3000
# もしくは
cloudflared tunnel --url http://localhost:3000
# https://random-name.trycloudflare.com が払い出される
払い出されたURLをStripe / GitHub / SlackのWebhook設定に登録すれば、ローカル環境で本番同様のWebhookをテストできます。ngrokの無料枠と比較して、Cloudflare Tunnelは安定性が高く開発者に人気です。
6.2 テストで使えるCLIツール
# Stripe: イベントを手動で送信
stripe trigger checkout.session.completed
# GitHub: CLIからWebhookを再送
gh api repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id} \
--method POST -f action=rerun
各サービスのCLIツールを使うと、実際のイベントを手元で再現できます。テスト用のシークレットを使い、シグネチャ検証の動作確認も忘れずに行いましょう。
7. GitHub / Slack / Stripeそれぞれの実装ポイント
主要サービスのWebhookは、細かい仕様が異なります。よく使う3サービスを比較しておきましょう。
| 項目 | GitHub | Slack | Stripe |
|---|---|---|---|
| 署名方式 | HMAC-SHA256(X-Hub-Signature-256) | HMAC-SHA256(X-Slack-Signature) | HMAC-SHA256(Stripe-Signature) |
| タイムスタンプ | なし | あり(X-Slack-Request-Timestamp) | あり(t=パラメータ) |
| イベントID | delivery id(管理UIで確認) | event_time / event_id | event.id(推奨) |
| リトライ | 30日間・間隔延長 | 3回程度 | 数日間・指数バックオフ |
| 再送手段 | UI/CLIから個別再送可 | 管理画面から | APIで個別再送可 |
Slackはタイムスタンプのずれ(±5分)で不正と判定するため、サーバー時刻の同期に注意が必要です。Stripeはt=の値が±5分以内であることを確認するのが推奨されています。タイムスタンプ検証を入れると、リプレイ攻撃(過去の有効なリクエストの再送)も防げます。
8. まとめ:本番で壊れないWebhookの5つの約束
Webhookは、正しく設計すれば外部サービス連携の信頼性の要になります。逆に、検証を省略すると誰でもあなたのサービスを操作できる重大な脆弱性になります。
- シグネチャ検証を必ず実装する — 送信元の確認は絶対条件。timingSafeEqualで定数時間比較。
- スキーマ検証で不正ボディを弾く — zod等で受信時点で弾き、想定外の形式を処理しない。
- べき等性を確保する — イベントIDで重複処理を防止。何度届いても1回だけ処理。
- 成功時は200、重い処理はキューへ — 受信は高速に返し、処理は非同期で。
- タイムスタンプ検証でリプレイ攻撃を防ぐ — 古いリクエストは捨てる。
個人開発のサービスでも、決済やCI/CD、チャット連携を組み込むならWebhookの設計は避けて通れません。最初はStripeの「受信→検証→200を返す」だけでも十分です。そこからべき等性・キュー・監視を段階的に足していけば、本番でも安心して運用できます。
この記事のコード例(Hono + Cloudflare Workers + D1)は、無料枠の範囲で完結する構成です。まずはローカル開発環境にトンネルを張って、実際のイベントを受信するところから始めてみてください。