結論:Webhook URL は最終到達先(www 付き)で登録する
# 登録前に必ず確認
curl -I -X POST https://あなたのドメイン/api/stripe/webhook
# 200 以外 → 要修正
masatoman.net で Stripe Webhook が 7 日間・14 件全失敗していたインシデントの記録です。
TL;DR
| 項目 | 詳細 |
|---|---|
| 期間 | 2026/4/24〜4/30(7 日間) |
| 失敗件数 | 14 件全滅 |
| 原因 | naked ドメインへの Webhook 登録 → Vercel 301 でボディ消滅 |
| 修正 | Stripe の Webhook URL を www. 付きに変更 |
| 復旧時間 | URL 変更後 即時 |
発生した事象
Stripe Dashboard → Developers → Webhooks → Recent deliveries を開くと、14 件全件に❌が並んでいました。
失敗したイベントのレスポンスボディ:
{
"redirect": "www.masatoman.net",
"status": 301
}
Vercel のログには何もエラーが出ていません。応答時間 293ms(Vercel はリクエストを受け取っていた)。
技術的な原因
Vercel は naked ドメイン(example.com)への全リクエストを www.example.com に 301 でリダイレクトします。
ブラウザは 301 を受け取ると自動的にリダイレクト先に再リクエストします。しかし Stripe は POST に対する 3xx を追いません。
理由: セキュリティ(RFC 7231 に基づき、POST の 301 フォローは任意。Stripe はリダイレクト先が信頼できるか検証できないため意図的に追わない設計)。
つまり:
Stripe → POST https://masatoman.net/api/stripe/webhook
↓
Vercel: 301 → www.masatoman.net
↓
Stripe: 失敗(body 喪失)
↓
www.masatoman.net には何も届かない
修正
Stripe Dashboard → Webhooks → Update details
変更前: https://masatoman.net/api/stripe/webhook
変更後: https://www.masatoman.net/api/stripe/webhook
変更直後にテストイベントを送信 → {"received": true} で即復旧。
失敗分の冪等再送
修正後、失敗した 14 件を Stripe Dashboard から手動再送します。
ただし、ハンドラーが冪等でない場合は二重処理になります。以下の実装を事前に入れておきましょう。
// app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// 冪等性チェック
const { data: existing } = await supabase
.from('processed_webhook_events')
.select('id')
.eq('stripe_event_id', event.id)
.single()
if (existing) {
return NextResponse.json({ received: true, skipped: true })
}
switch (event.type) {
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.Invoice)
break
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
break
}
await supabase
.from('processed_webhook_events')
.insert({ stripe_event_id: event.id, processed_at: new Date().toISOString() })
return NextResponse.json({ received: true })
}
-- Supabase マイグレーション
CREATE TABLE processed_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stripe_event_id TEXT UNIQUE NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON processed_webhook_events (stripe_event_id);
再発防止:smoke test
# .github/workflows/webhook-smoke-test.yml
name: Webhook Smoke Test
on:
schedule:
- cron: '0 */6 * * *'
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Verify webhook endpoint is reachable
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST https://www.masatoman.net/api/stripe/webhook \
-H "Content-Type: application/json" \
-d '{"type":"__health_check__"}')
# 署名なし → 400 が正常(エンドポイントに到達できている証拠)
if [ "$STATUS" = "301" ] || [ "$STATUS" = "302" ] || [ "$STATUS" = "000" ]; then
echo "ALERT: Status $STATUS suggests redirect or unreachable"
exit 1
fi
echo "OK: Status $STATUS"
やること 3 つ
-
curl -I -X POSTで Webhook URL を検証(今すぐ) - Stripe の Webhook 失敗通知メールを有効化(Settings > Email notifications)
-
processed_webhook_eventsテーブルで冪等性を確保
この実装の詳細(Supabase との連携・イベントハンドラー設計・冪等再送の完全コード)は masatoman.net の記事で公開しています。