個人開発SaaSの支払い失敗リカバリー設計2026 — Stripe Dunning × Supabase で不本意チャーンを自動回収する
結論:3ファイルで支払い失敗リカバリーの骨格が完成する
Stripe Webhook ハンドラ + Supabase ステータス更新 + Resend メール通知の3点セットで、支払い失敗を検知して自動回収する「Dunning(ダニング)」の基盤が作れます。
// 最小構成のフロー
invoice.payment_failed (Stripe Webhook)
→ subscriptions テーブルの status を 'past_due' に更新 (Supabase)
→ 支払い失敗メール + Billing Portal URL を送信 (Resend)
→ invoice.paid (Stripe Webhook)
→ status を 'active' に戻す + 回収ログを記録
この記事では、個人開発SaaSで必ず直面する「不本意チャーン」の問題を、実コード付きで解決します。
masatoman.net で Claude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、実際の支払い失敗・回収データはまだありません。この記事で紹介する実装パターンは、Stripe 公式ドキュメント・Baremetrics 業界レポート・ChartMogul ベンチマークをベースにしており、筆者がテスト環境で動作確認したコードを含みます。
この記事でわかること:
- 不本意チャーンとは何か、なぜ個人開発SaaSで見落とされやすいか
- Stripe のスマートリトライ(自動リトライ)の設定方法
invoice.payment_failedWebhook を Supabase と連携する実装- Resend で支払い失敗通知メールを送る実装
- Stripe Billing Portal でカード更新を誘導する実装
- 「で、どう稼ぐ?」— 回収1件が個人開発収益にどう効くか
不本意チャーンとは — 個人開発者が見落とす静かな収益漏れ
チャーン(解約)には2種類あります。
| 種類 | 原因 | 対処法 |
|---|---|---|
| 自発的チャーン(Voluntary Churn) | ユーザーが意図的に解約 | 価値向上・リテンション施策 |
| 不本意チャーン(Involuntary Churn) | カード失効・残高不足・銀行拒否などで強制解約 | Dunning(支払いリカバリー)設計 |
Baremetrics の業界調査によると、SaaS の月次チャーンのうち 20〜40% が不本意チャーンとされています。つまり「解約したくないのに支払いエラーで切れてしまう」ユーザーが、全チャーンの2〜4割を占めます。
個人開発者がこれを見落とす理由は明確です。Stripe のダッシュボードで「サブスクリプションが解約された」と表示されたとき、それが自発的解約か支払い失敗による強制解約かを区別する仕組みを作っていないからです。
支払い失敗は カード更新の連絡さえすれば回収できる可能性が高い ため、放置は純粋な機会損失です。
Step 0:Stripe のスマートリトライを有効にする
まず Stripe 側の設定から始めます。Stripe には「スマートリトライ」と呼ばれる自動リトライ機能があり、支払い失敗後に機械学習で最適なタイミングを選んで最大4回まで自動リトライしてくれます。
設定場所: Stripe ダッシュボード → 課金 → サブスクリプションと請求 → 失敗した支払いの管理
設定項目:
- 自動リトライを 有効
- リトライスケジュール: スマートリトライ(推奨)
- 最終リトライ後の処理: サブスクリプションをキャンセル
この設定だけで Stripe 側のリトライは完了します。あとは Webhook で通知を受け取る実装です。
Step 1:Webhook で支払い失敗イベントを捕捉する
Stripe から受け取る主要イベント:
| イベント | タイミング | 対処 |
|---|---|---|
invoice.payment_failed | 支払い失敗(リトライ含む各回) | ユーザーに通知 + DB更新 |
customer.subscription.updated | status: 'past_due' に変化 | アクセス制限を検討 |
invoice.paid | リトライ成功 or 手動更新で支払い完了 | ステータスを active に戻す |
customer.subscription.deleted | 最終リトライも失敗、サブスク終了 | 解約処理 + 再登録促進メール |
Webhook ハンドラの実装
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { handlePaymentFailed, handlePaymentRecovered } from '@/lib/dunning'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: NextRequest) {
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: 'Webhook signature failed' }, { status: 400 })
}
switch (event.type) {
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
case 'invoice.paid':
await handlePaymentRecovered(event.data.object as Stripe.Invoice)
break
}
return NextResponse.json({ received: true })
}
Step 2:Supabase でユーザーステータスを past_due に更新する
Supabase に subscriptions テーブルを用意し、支払い状態を管理します。
テーブル構造
-- supabase/migrations/add_dunning_fields.sql
ALTER TABLE subscriptions
ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active',
ADD COLUMN IF NOT EXISTS payment_failed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS payment_failed_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recovered_at TIMESTAMPTZ;
ステータス更新ロジック
// lib/dunning.ts
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'
import { sendPaymentFailedEmail, sendPaymentRecoveredEmail } from '@/lib/emails'
export async function handlePaymentFailed(invoice: Stripe.Invoice) {
const supabase = createClient()
const customerId = typeof invoice.customer === 'string'
? invoice.customer
: invoice.customer?.id
if (!customerId) return
const { data: profile } = await supabase
.from('profiles')
.select('id, email, name')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) return
// サブスクリプションのステータスを past_due に更新
await supabase
.from('subscriptions')
.update({
status: 'past_due',
payment_failed_at: new Date().toISOString(),
payment_failed_count: supabase.rpc('increment', { row_id: profile.id }),
})
.eq('user_id', profile.id)
// Stripe Billing Portal URL を生成
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`,
})
// ユーザーに通知メールを送信
await sendPaymentFailedEmail({
to: profile.email,
name: profile.name ?? 'ユーザー',
billingPortalUrl: session.url,
})
}
export async function handlePaymentRecovered(invoice: Stripe.Invoice) {
const supabase = createClient()
const customerId = typeof invoice.customer === 'string'
? invoice.customer
: invoice.customer?.id
if (!customerId) return
const { data: profile } = await supabase
.from('profiles')
.select('id, email, name')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) return
// ステータスを active に戻す
await supabase
.from('subscriptions')
.update({
status: 'active',
recovered_at: new Date().toISOString(),
})
.eq('user_id', profile.id)
// 回収成功メールを送信
await sendPaymentRecoveredEmail({
to: profile.email,
name: profile.name ?? 'ユーザー',
})
}
Step 3:Resend で支払い失敗通知メールを送る
通知メールのトーンは重要です。「支払いに失敗しました」と責める文面ではなく、「手続きをお手伝いします」という誘導型にします。
// lib/emails.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendPaymentFailedEmail({
to,
name,
billingPortalUrl,
}: {
to: string
name: string
billingPortalUrl: string
}) {
await resend.emails.send({
from: 'support@masatoman.net',
to,
subject: `【要対応】お支払いの確認をお願いします`,
html: `
<p>${name} さん、いつもご利用ありがとうございます。</p>
<p>
先ほどのお支払い処理で一時的なエラーが発生しました。<br />
カードの有効期限切れや残高不足の場合、下記から簡単に更新できます。
</p>
<p>
<a href="${billingPortalUrl}" style="background:#4f46e5;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">
お支払い情報を確認する →
</a>
</p>
<p style="color:#666;font-size:12px;">
このまま放置すると、サービスへのアクセスが停止される場合があります。<br />
お支払いが確認できた時点で、引き続きご利用いただけます。
</p>
`,
})
}
export async function sendPaymentRecoveredEmail({
to,
name,
}: {
to: string
name: string
}) {
await resend.emails.send({
from: 'support@masatoman.net',
to,
subject: `お支払いが完了しました — ありがとうございます`,
html: `
<p>${name} さん、お支払いのご確認をありがとうございました。</p>
<p>正常に処理が完了しました。引き続きサービスをご利用ください。</p>
`,
})
}
Step 4:Stripe Billing Portal を有効化する
Billing Portal は Stripe が提供するカード更新・請求履歴確認のホスト済みページです。自前でカード更新フォームを実装する必要がなく、PCI DSS 対応も Stripe 側が担います。
有効化手順:
- Stripe ダッシュボード → Customer portal → 設定を有効化
- 許可する操作を選択(支払い方法の更新 + 請求履歴の閲覧を推奨)
- ロゴ・カラーを設定(ブランドカラーに合わせる)
コード側では stripe.billingPortal.sessions.create() で都度 URL を発行します(セッションは使い捨て・有効期限あり)。
// app/api/billing-portal/route.ts
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: NextRequest) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single()
if (!profile?.stripe_customer_id) {
return NextResponse.json({ error: 'No customer found' }, { status: 404 })
}
const session = await stripe.billingPortal.sessions.create({
customer: profile.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`,
})
return NextResponse.json({ url: session.url })
}
ダッシュボード画面に「支払い情報を管理する」リンクを置いておくと、支払い失敗メールを見たユーザーがダッシュボードに戻ったときにも更新できます。
// components/BillingPortalButton.tsx
'use client'
export function BillingPortalButton() {
const handleClick = async () => {
const res = await fetch('/api/billing-portal', { method: 'POST' })
const { url } = await res.json()
window.location.href = url
}
return (
<button
onClick={handleClick}
className="text-sm text-blue-600 underline hover:no-underline"
>
お支払い情報を管理する →
</button>
)
}
Step 5:past_due ユーザーへのアクセス制御(任意)
支払い失敗中のユーザーをどう扱うかはプロダクトの判断です。一般的な選択肢:
| 選択肢 | メリット | デメリット |
|---|---|---|
| アクセス継続(Stripe のリトライを待つ) | 摩擦が少なく回収しやすい | 支払いなしで利用継続される期間が生まれる |
| 機能を一部制限(グレースピリオド設計) | 損失を限定しつつ回収チャンスを保持 | 実装コストがかかる |
| 即時アクセス停止 | 損失ゼロ | 回収率が下がる(ユーザーが離脱しやすい) |
個人開発初期は「アクセス継続 + 通知メール」が最も実装コストが低く、回収率も高い選択です。Stripe のスマートリトライが4回失敗して初めてサブスクリプションが終了するため、その間に多くのユーザーが対応してくれます。
Supabase RLS でアクセス制御する場合:
-- subscriptions.status = 'past_due' の場合、特定テーブルへのアクセスを制限する例
CREATE POLICY "active_subscribers_only"
ON premium_content
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM subscriptions
WHERE user_id = auth.uid()
AND status = 'active'
)
);
で、どう稼ぐ?
Dunning 設計を「稼ぐ」視点で整理します。
不本意チャーン回収の収益インパクト試算
以下は 業界ベンチマークを使った試算例(実測値ではなく計算モデル)です。
Baremetrics のデータによると、Dunning 施策を実装した SaaS では不本意チャーンの 回収率が平均 40〜60% 改善すると報告されています。
| 状況 | 前提(業界ベンチマーク試算) | 月次インパクト試算 |
|---|---|---|
| Dunning なし | 支払い失敗 = ほぼ全件チャーン | — |
| Dunning あり | 失敗件数の 40〜60% を回収 | 回収1件 = ¥1,980/月の維持 |
| 年間換算(回収1件あたり) | 継続12ヶ月の場合 | ¥23,760 の損失回避試算 |
これはあくまで試算です。実際の回収率は業種・価格帯・通知タイミングによって異なります。
個人開発での優先度
Dunning 設計は「失う収益を止める」施策であり、新規獲得よりも即効性があります。サブスクリプションが1件でも動いている状態なら、最初に実装すべきチャーン防止策の筆頭です。
実装コスト目安:
- Webhook ハンドラ: 2〜3時間
- Resend メール通知: 1〜2時間
- Billing Portal 設定 + ボタン: 30分
合計4〜6時間の実装で、支払い失敗による損失を自動回収できる仕組みが完成します。
Claude Crew Lab との連携
Standard(¥1,980/月)が稼働した時点で、この Dunning 設計を最初のインフラとして組み込む予定です。不本意チャーンは「サービスが気に入っているのに失ってしまう顧客」を生むため、ユーザー体験の観点でも早期対応が重要です。ローンチ準備段階の今、実装を先行させています。
関連記事
Stripe Webhook × Supabase でチャーン検知を自動化する2026
解約の予兆を捕まえて収益を守る実装ガイド
個人開発SaaSのLTV設計2026
アップセル×リテンションで1ユーザーから最大収益を引き出す実装ガイド
個人開発のメール自動化2026
Resend+Supabaseで無料→有料転換を仕組み化する実践ガイド
Claude Crew Lab Free — 毎月の実験記録をメールで
Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。
個人開発の実験ログを月1回、無料で
失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。
この記事が役に立ったらシェア