Stripe Webhook 全失敗 1 週間の原因 — naked/www リダイレクト罠と再送手順
結論:Webhook URL は必ず最終到達先(www 付き)で登録する
3行サマリー:
- Stripe Webhook は 3xx を返したエンドポイントを「失敗」扱いしてリトライキューに入れる
- Vercel はデフォルトで naked ドメイン(
masatoman.net)→ www に 301 リダイレクトする - Stripe ダッシュボードの Webhook URL を
www.masatoman.net/...に変えるだけで即回復
今すぐ確認するチェックリスト:
# ① Stripe Dashboard で登録している Webhook URL を確認
# (Settings > Webhooks)
# ② 登録 URL が最終到達先かどうかを curl で検証
# (実際の Webhook と同じ POST + ボディで叩く)
curl -i -X POST https://あなたのドメイン/api/stripe/webhook \
-H "Content-Type: application/json" \
-d '{}'
# レスポンスの 1 行目が「HTTP/2 301」「HTTP/2 302」なら要修正
# 「HTTP/2 400」(署名なしリクエストの正常応答)が返れば到達 OK
何が起きていたか — 7 日間・14 件全失敗の実録
masatoman.net でこの失敗が発生したのは 2026 年 4 月 24 日〜4 月 30 日の 7 日間。Stripe ダッシュボードの Webhook ログでは、その期間の 14 件全イベント(invoice.payment_succeeded / customer.subscription.updated / invoice.payment_failed ほか)が ❌ 失敗ステータスになっていました。
しかし Vercel のデプロイログには「エラー」の記録はありません。アプリは正常に動いているように見えていました。
最初は原因が全くわかりませんでした。
Webhook のハンドラーコード(app/api/stripe/webhook/route.ts)に問題があると思い込んで、stripe.webhooks.constructEvent() の実装やシークレットキーを何度も確認しました。でも問題はコードではありませんでした。
失敗したイベントの「Response」タブを開くと、ステータスコードと Location ヘッダーだけが返っていました:
HTTP/1.1 301 Moved Permanently
Location: https://www.masatoman.net/api/stripe/webhook
これが答えでした。Webhook ハンドラーは一度も実行されていなかった。Vercel が naked ドメインへのリクエストを 301 で返した時点で終わっていたのです。
なぜ POST → 301 で Webhook が死ぬのか
HTTP の仕様と Stripe の設計が組み合わさって生まれるトラップです。
Vercel の naked ドメイン処理
Vercel は masatoman.net(naked ドメイン)へのリクエストを www.masatoman.net へ 301 リダイレクトする設定になっています。これはブラウザのアクセスでは問題ありません。ブラウザは 301 を受け取ると自動的に新しい URL へ再リクエストを送ります。
しかし POST リクエストの場合、挙動が根本的に異なります。
Stripe が Webhook でリダイレクトを追わない
Stripe の Webhook は、エンドポイントが 2xx 以外(3xx 含む)を返した時点でリトライキュー行きになります。RFC 9110(旧 RFC 7231)では POST に対する 301 を受けたクライアントの挙動はクライアント任せですが、Stripe Webhook は 3xx を成功扱いせずリトライ対象に倒す設計になっています(Stripe Docs: Webhook endpoint best practices 参照)。
その結果、Stripe が masatoman.net/api/stripe/webhook に POST を送ると:
- Vercel が
HTTP 301+Location: www.masatoman.net/api/stripe/webhookを返す - Stripe は「エンドポイントが 2xx を返さなかった = 失敗」と判定してリトライキューに入れる
- ハンドラーコード自体は一度も実行されない
ハンドラーは動いていないので、当然アプリ側のログには何も出ません。これが「ログにエラーがない」のに失敗が続いた理由です。
発見のきっかけ
7 日間気づかなかった理由は、アプリが表面上は正常だったからです。
Stripe の Webhook が失敗していても、Supabase のユーザーテーブルに記録されないだけで、Stripe 側の課金自体は成立しています。購入した(しようとした)ユーザーに対して「購入成功」のフィードバックが届かない、権限が付与されないという問題が起きていたのですが、Phase 0 でユーザー数が少ない状況だったため問い合わせがなく気づけませんでした。
気づいたきっかけは、Stripe ダッシュボードの Webhooks タブを定期確認するようにしたことです。
Stripe ダッシュボード → Developers → Webhooks → エンドポイントを選択 → Recent deliveries
ここを見ると過去のイベントの成否が一覧表示されます。全件❌になっているのを発見したのがこのインシデントの始まりです。
修正手順
3 ステップで完結します(所要時間:5 分)。
ステップ 1: 現状の Webhook URL を確認
Stripe ダッシュボード → Developers → Webhooks → 対象エンドポイントを選択
登録されている URL を確認します。
ステップ 2: URL を最終到達先に変更
「Update details」から URL を変更します。
変更前: https://masatoman.net/api/stripe/webhook
変更後: https://www.masatoman.net/api/stripe/webhook
www の有無が分からない場合は、結論セクションで紹介した curl -i -X POST で確認します。Location: ヘッダーが返らず、ボディがハンドラーから返ってくる URL を使います。
ステップ 3: テストイベントで即時確認
Stripe ダッシュボード → Webhooks → 「Send test webhook」で任意のイベントを送信します。
{
"received": true
}
このレスポンスが返ってきたら修正完了です。私の場合、URL 変更後の最初のテストで即座に 200 OK が返ってきました。
失敗した 14 件の冪等再送
Webhook URL を修正した後、過去 7 日間に失敗した 14 件のイベントを再送する必要がありました。
再送の前に冪等性を確認する
同じイベントを二重処理しないよう、ハンドラーが冪等であることを確認します。
ここでよくある間違いが、SELECT で「処理済みか確認」してから INSERT で記録するパターンです。これは並列で同じ event が届いたときに両方とも SELECT で空を見て両方処理してしまう race condition が残ります。代わりに UNIQUE 制約の INSERT 失敗を「処理済み」と解釈する パターンを使います。
CREATE TABLE processed_webhook_events (
stripe_event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
// 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 (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// ① まず先に「処理済みフラグ」を立てる。
// PRIMARY KEY 衝突なら「すでに別プロセスが処理済み」と判定して 200 でスキップ。
const { error: claimError } = await supabase
.from('processed_webhook_events')
.insert({ stripe_event_id: event.id })
if (claimError?.code === '23505') {
// unique_violation → 別プロセスが処理中/処理済み
return NextResponse.json({ received: true, skipped: true })
}
if (claimError) {
// それ以外の DB エラーは Stripe にリトライさせる
return NextResponse.json({ error: 'DB error' }, { status: 500 })
}
// ② ここまで来たのは「自分だけが INSERT に成功した」場合のみ
// 本処理を走らせる
// ... event.type に応じた処理 ...
return NextResponse.json({ received: true })
}
INSERT を先に走らせる「claim-first」パターンにすると、同イベントが並列で届いても unique_violation で必ずどちらか片方だけが本処理に進みます。本処理側の失敗時のロールバックは別途設計が必要なので、件数が増えてきたら処理 ID + ステータス(claimed / done / failed)の 3 状態管理に拡張してください。
Stripe ダッシュボードから再送
Stripe ダッシュボード → Developers → Webhooks → Recent deliveries → 失敗したイベントを選択 → 「Resend」
14 件すべてを手動で再送しました。冪等性が実装されているので、仮に一部が二重送信されても問題ありません。
再発防止:Webhook 死活監視の実装
今回のインシデントで得た最大の教訓は「Webhook の失敗はサイレントに起きる」ということです。
Vercel や Next.js のエラーログには何も出ません。Stripe ダッシュボードを定期的に確認しない限り、数週間気づかない可能性があります。
Stripe が提供するアラート機能を使う
Stripe ダッシュボード → Settings → Email notifications → 「Webhook failures」にチェックを入れると、エンドポイントの失敗率が閾値を超えたときにメールが届きます。
これを最初に設定しておけば 7 日間の沈黙は防げました。 無料で使えるのに見落としがちな設定です。
smoke test で継続監視
masatoman.net では auth callback の監視に倣い、Stripe Webhook の smoke test を GitHub Actions で実装しました。
# .github/workflows/webhook-smoke-test.yml
name: Webhook Smoke Test
on:
schedule:
- cron: '0 */6 * * *' # 6時間ごと
push:
branches: [main]
jobs:
webhook-health:
runs-on: ubuntu-latest
steps:
- name: Check webhook endpoint
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 '{}')
echo "HTTP status: $STATUS"
# 今回の事故と同じ「301 / 302 が返る = 到達していない」を最優先で検出
if [ "$STATUS" = "301" ] || [ "$STATUS" = "302" ] || [ "$STATUS" = "307" ] || [ "$STATUS" = "308" ]; then
echo "::error::Webhook URL is returning a redirect. Stripe will treat this as failure."
exit 1
fi
# 署名なしの POST に対しては 400(署名検証失敗)が正常応答
if [ "$STATUS" != "400" ]; then
echo "::error::Unexpected status (expected 400 for unsigned request)"
exit 1
fi
署名なしのリクエストを送ると Stripe の署名検証が失敗して 400 が返ります。これは「エンドポイントに到達できている」ことの証明です。3xx が返ったらまさに今回の事故が起きている状態なので、3xx だけは別メッセージで明示的に exit 1 するようにしておくと原因特定が一瞬で済みます。
事故予防 3 原則(同じ罠を踏まないために)
今回の masatoman.net の Phase 0 では、Webhook が 7 日間死んでいた期間中に Stripe ダッシュボード上で課金成立した実イベントは 14 件あったものの、そのうち実際の購入完了は 0 件でした(テストモードでの自分の操作が大半 + サブスク自動更新の少数)。実損ゼロで済んだのは単に「ユーザーがいなかったから」で、ユーザーが増えた後で同じ罠を踏むと、購入したのに権限が付かないインシデントが直に売上と信頼に効いてきます。
個人開発 SaaS で Stripe Webhook が使われる主なシーン:
invoice.payment_succeeded→ 購入後のサービス有効化customer.subscription.created→ サブスク開始の記録invoice.payment_failed→ 支払い失敗時のユーザー通知
どれも「届かなくてもしばらく気づかない」のが共通の特徴です。事故が起きる前に以下 3 つを仕込んでおくのが安いです。
1. Webhook URL は登録前に curl で事前検証
curl -i -X POST https://あなたのドメイン/api/stripe/webhook \
-H "Content-Type: application/json" \
-d '{}'
# Location: ヘッダーが返ったら危険(リダイレクト中)
# 400(署名検証失敗)が返ったら到達 OK
ローカル開発では stripe listen --forward-to localhost:3000/api/stripe/webhook を使いましょう。
2. INSERT-first パターンで二重処理を防ぐ
上述の processed_webhook_events の claim-first パターンは金銭の二重処理を防ぐ実装です。「Resend ボタンを連打」「複数インスタンスが同時に受け取る」のいずれでも 1 回しか走らないことを、テスト環境で実際に同じイベントを 2 回送って確かめてください。
3. Stripe の失敗通知メールを必ず有効化
設定 1 分。しかしこれだけで「数週間の沈黙」を「数時間で気づける状態」に変えられます。設定箇所は Settings → Email notifications → Webhook failures。
masatoman.net で実際に発生したインシデントです。2026 年 4 月 24 日〜30 日、Stripe ダッシュボード上で Webhook 14 件全失敗(リダイレクトにより 2xx が一度も返らなかった)を 7 日後に発見。原因は naked ドメイン(masatoman.net)への Webhook 登録で、Vercel の 301 リダイレクトにより Stripe がリトライキュー扱いし、ハンドラーが一度も実行されていませんでした。修正(www. 付き URL への変更 + 14 件の Resend)後、署名なし POST に対して 400 が返ること、Resend したテストイベントが 200 で処理完了することを確認しています。現在は 6 時間ごとの smoke test(3xx を専用メッセージで検知)と Stripe の Webhook 失敗通知メールで監視中。
まとめ:今日やること 3 つ
- Stripe ダッシュボードの Webhook URL を
curl -i -X POSTで検証する —Location:ヘッダーが返ったら即 www. 付きに変更 - Stripe の Webhook 失敗通知メールを有効化する — Settings → Email notifications → Webhook failures
- ハンドラーに claim-first 冪等性チェック(
processed_webhook_eventsテーブル)を実装する — 再送時の二重処理を防止
このトラブルは Stripe + Vercel 構成の個人開発者なら誰でも踏む可能性があります。事前確認に 5 分かけるだけで、数週間気づかない静かな失敗を防げます。
Stripe を「自前ペイウォール」として使う具体的な実装手順(署名検証・サブスク状態の Supabase 同期・購入後のアクセス制御)は、有料記事「個人開発の最初の 1 売上 — Stripe 自前ペイウォール完全ガイド」(¥1,000)に書いています。
masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通
masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
Next Step
次に読むならこの導線です
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
Claude Code で 0→MVP を1日で作る全記録 — recipe-ai Build in Public
Claude Codeを使い、YouTube料理動画からレシピを自動抽出するAIアプリ「recipe-ai」を0からMVPまで1日で構築した全記録。CLAUDE.md設計、API実装、Supabase連携、Vercelデプロイ、Stripe課金導入までの工程を時系列で公開。
【第1回】Claude Code Skills 入門 — 自作スキルで開発効率を2倍にする実装ガイド
Claude Code の Skills 機能を自作する手順を、masatoman.net 周辺の自動化を Claude Code で回している立場で実コード付きで解説。1 スキル 15 分の投資で月 10 時間の作業を削減する実装ガイドです。
次の実験記録も追う
Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
この記事が役に立ったらシェア