1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stripe Webhook が 301 で 1 週間全失敗した話 — naked ドメインと Vercel リダイレクトの罠

1
Last updated at Posted at 2026-05-17

結論: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 つ

  1. curl -I -X POST で Webhook URL を検証(今すぐ)
  2. Stripe の Webhook 失敗通知メールを有効化(Settings > Email notifications)
  3. processed_webhook_events テーブルで冪等性を確保

この実装の詳細(Supabase との連携・イベントハンドラー設計・冪等再送の完全コード)は masatoman.net の記事で公開しています。

Stripe Webhook 全失敗 1 週間の原因 — naked/www リダイレクト罠と再送手順

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?