Stripe Webhook × Supabase でチャーン検知を自動化する2026 — 解約の予兆を捕まえて収益を守る
結論:Stripe Webhook の5イベントを監視すれば解約の予兆が分かる
Stripe と Supabase を組み合わせた SaaS では、次の5つの Webhook イベントを追跡することで「解約予備軍」を自動検知できます。
invoice.payment_failed— 支払い失敗(最も緊急)customer.subscription.updated— プランダウングレードcustomer.subscription.deleted— 解約実行済みinvoice.upcoming— 請求直前(解約検討タイミング)customer.updated— 支払い方法・メールアドレス変更
これらを Supabase の churn_events テーブルに記録し、Resend で自動フォローメールを送る仕組みを実装します。支払い失敗の翌日に「カード情報を更新してください」メールを自動送信するだけでも、不本意なチャーン(意図せず失われる解約)を大幅に減らせます。
masatoman.net では現在、記事80本以上を公開しClaude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、実測チャーン率は現時点で公開できる段階にありません。この記事で紹介する実装パターンは、SaaS業界の一般的な知見(Stripe公式ドキュメント、Baremetrics等)と筆者がWebhook実装で実際に動作確認したコードをベースにしています。
なぜチャーン対策が個人開発の最優先課題になるのか
新規獲得コストは継続コストの5〜7倍
マーケティング調査会社 Bain & Company の研究によれば、新規顧客獲得コストは既存顧客の維持コストの5〜7倍かかるとされています。個人開発者にとっては、SNS投稿や記事執筆に費やす時間も含めると、その差はさらに開きます。
月額¥1,980 のサブスクを1人解約されると、その穴を埋めるには新規ユーザーを1人獲得する必要があります。しかし実際には、新規獲得には複数の記事作成・SNS発信・LP改善が必要です。つまり 「解約を1件防ぐ」ことは「新規を1件獲得する」より何倍も効率的 です。
個人開発に多い「不本意なチャーン」
SaaS のチャーンには2種類あります:
- 自発的チャーン: ユーザーが意図して解約する
- 不本意なチャーン(Involuntary Churn): 支払い失敗・カード期限切れなどで意図せず失われる
Stripe の調査では、サブスクリプション収益の損失のうち、不本意なチャーンが 20〜40% を占めると言われています。これは Webhook + 自動メールで対処できる、個人開発者にとって最も取り組みやすいチャーン対策です。
実装の全体像
Stripe Event → Next.js API Route → Supabase churn_events → Resend メール
↓
サブスクステータス更新 (subscriptions テーブル)
必要なものは4つです:
| ツール | 役割 | 月額コスト |
|---|---|---|
| Stripe | 決済・Webhook発火 | 手数料3.6%のみ |
| Supabase | イベント記録・ユーザー管理 | 無料枠あり |
| Resend | 自動メール送信 | 月3,000通まで無料 |
| Next.js | Webhook受信API | Vercelで無料ホスト |
ステップ1:Supabase に churn_events テーブルを作成
まずイベントを記録するテーブルを作成します。
create table churn_events (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id),
stripe_customer_id text not null,
event_type text not null,
event_data jsonb,
handled boolean default false,
created_at timestamptz default now()
);
-- RLS: サービスロールのみ書き込み可能
alter table churn_events enable row level security;
create policy "service_role_only" on churn_events
using (auth.role() = 'service_role');
event_type には payment_failed, downgraded, cancelled, upcoming_invoice, payment_method_updated などを記録します。handled フラグでフォロー済みかどうかを管理します。
ステップ2:Next.js Webhook エンドポイントを実装
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'
import { sendChurnFollowUpEmail } from '@/lib/resend'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_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: 'Invalid signature' }, { status: 400 })
}
await handleStripeEvent(event)
return NextResponse.json({ received: true })
}
async function handleStripeEvent(event: Stripe.Event) {
const handlers: Record<string, (event: Stripe.Event) => Promise<void>> = {
'invoice.payment_failed': handlePaymentFailed,
'customer.subscription.updated': handleSubscriptionUpdated,
'customer.subscription.deleted': handleSubscriptionDeleted,
'invoice.upcoming': handleUpcomingInvoice,
}
const handler = handlers[event.type]
if (handler) await handler(event)
}
支払い失敗ハンドラー(最重要)
async function handlePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice
const customerId = invoice.customer as string
// Supabase で対応ユーザーを検索
const { data: profile } = await supabase
.from('profiles')
.select('id, email, name')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) return
// churn_events に記録
await supabase.from('churn_events').insert({
user_id: profile.id,
stripe_customer_id: customerId,
event_type: 'payment_failed',
event_data: {
invoice_id: invoice.id,
amount_due: invoice.amount_due,
attempt_count: invoice.attempt_count,
},
handled: false,
})
// 初回失敗のみ即座にメール送信(2回目以降はStripeのDunning設定に任せる)
if (invoice.attempt_count === 1) {
await sendChurnFollowUpEmail({
to: profile.email,
name: profile.name,
type: 'payment_failed',
})
}
}
ダウングレードハンドラー
async function handleSubscriptionUpdated(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription
const previousAttributes = event.data.previous_attributes as Record<string, unknown>
// プランが下がった場合のみ記録
const isDowngrade =
previousAttributes?.items &&
subscription.items.data[0].price.unit_amount! <
(previousAttributes.items as Stripe.SubscriptionItem[])[0]?.price?.unit_amount!
if (!isDowngrade) return
const customerId = subscription.customer as string
await supabase.from('churn_events').insert({
stripe_customer_id: customerId,
event_type: 'downgraded',
event_data: { subscription_id: subscription.id },
handled: false,
})
}
ステップ3:Resend でフォローメールを送る
// lib/resend.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
type ChurnEmailType = 'payment_failed' | 'downgraded' | 'cancelled'
interface ChurnEmailParams {
to: string
name: string
type: ChurnEmailType
}
const emailTemplates: Record<ChurnEmailType, { subject: string; body: string }> = {
payment_failed: {
subject: '【重要】お支払いが処理できませんでした',
body: `{name} さん、\n\n先ほどのお支払いが処理できませんでした。\nカード情報の確認・更新をお願いします:\n\nhttps://masatoman.net/billing\n\nご不明な点はお気軽にご返信ください。`,
},
downgraded: {
subject: 'プランを変更されましたか?お困りのことがあれば',
body: `{name} さん、\n\nプランの変更を確認しました。\n何かご不満な点や、お困りのことがあればお気軽にこのメールに返信してください。\n\nmasato`,
},
cancelled: {
subject: 'ご利用ありがとうございました',
body: `{name} さん、\n\nこれまでのご利用、ありがとうございました。\nもし再開をご検討の際は、いつでもお待ちしております。\n\nmasato`,
},
}
export async function sendChurnFollowUpEmail({ to, name, type }: ChurnEmailParams) {
const template = emailTemplates[type]
const body = template.body.replace('{name}', name)
await resend.emails.send({
from: 'masato@masatoman.net',
to,
subject: template.subject,
text: body,
})
}
ステップ4:Stripe Webhook を本番登録する
Stripe ダッシュボード → Developers → Webhooks で以下のイベントを登録します:
invoice.payment_failed
invoice.upcoming
customer.subscription.updated
customer.subscription.deleted
customer.updated
ローカルテストは Stripe CLI で行います:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger invoice.payment_failed
ステップ5:未対応イベントを定期的に確認する(オプション)
handled = false のイベントが溜まっていないか、Supabase Edge Function or cron で週次確認できます:
// supabase/functions/churn-audit/index.ts
import { createClient } from '@supabase/supabase-js'
Deno.serve(async () => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { data } = await supabase
.from('churn_events')
.select('*')
.eq('handled', false)
.gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString())
if (data && data.length > 0) {
console.log(`未処理チャーンイベント: ${data.length} 件`)
}
return new Response('ok')
})
で、どう稼ぐ?
チャーン対策は「MRR防衛」の最も費用対効果が高い手段
チャーン検知を実装する直接的な目的は、失いかけているサブスク収益を守ることです。
試算例(あくまで業界ベンチマーク準拠の想定値):
| シナリオ | 月額¥1,980 × ユーザー数 | 月間収益 |
|---|---|---|
| 30人継続 | ¥1,980 × 30 | 約¥59,400 |
| 不本意チャーン20%(試算) | -(¥1,980 × 6) | -¥11,880 |
| Webhook対策で50%回収(試算) | +(¥1,980 × 3) | +¥5,940 |
不本意チャーンの半分を回収するだけで、月額¥6,000近い収益防衛になります。これは新記事を1本書いて新規ユーザーを3人獲得するよりも現実的な数字です(※上記は業界水準ベースの試算例であり、筆者実績ではありません)。
Claude Crew Lab との連携
Claude Crew Lab(Free)では、Webhook 実装に関するQ&Aや設定レビューも扱っています。Free 登録後に「チャーン対策を実装したい」とメッセージをいただければ、設定の見直しや詰まりポイントのサポートをします。
Claude Crew Lab Free — 毎月の実験記録をメールで
Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。
Lab Free 登録(月1回・無料)
まとめ:3つのファイルで実装できるチャーン防衛ライン
| ファイル | 役割 |
|---|---|
app/api/webhooks/stripe/route.ts | Webhook受信・イベント分岐 |
lib/resend.ts | フォローメール送信 |
Supabase churn_events テーブル | イベント記録・監査 |
チャーン対策は「売れるようになってから」ではなく、最初のサブスクユーザーが入った瞬間から必要です。Webhook エンドポイントは一度作れば自動で動き続けます。まずは invoice.payment_failed の1イベントから始めてみてください。
関連記事
この記事が役に立ったらシェア