メインコンテンツへスキップ
← 記事一覧に戻る
·収益化·11 min read

Stripe Webhook × Supabase でチャーン検知を自動化する2026 — 解約の予兆を捕まえて収益を守る

StripeSupabase個人開発チャーンSaaS

結論:Stripe Webhook の5イベントを監視すれば解約の予兆が分かる

Stripe と Supabase を組み合わせた SaaS では、次の5つの Webhook イベントを追跡することで「解約予備軍」を自動検知できます。

  1. invoice.payment_failed — 支払い失敗(最も緊急)
  2. customer.subscription.updated — プランダウングレード
  3. customer.subscription.deleted — 解約実行済み
  4. invoice.upcoming — 請求直前(解約検討タイミング)
  5. 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.jsWebhook受信APIVercelで無料ホスト

ステップ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.tsWebhook受信・イベント分岐
lib/resend.tsフォローメール送信
Supabase churn_events テーブルイベント記録・監査

チャーン対策は「売れるようになってから」ではなく、最初のサブスクユーザーが入った瞬間から必要です。Webhook エンドポイントは一度作れば自動で動き続けます。まずは invoice.payment_failed の1イベントから始めてみてください。


関連記事

この記事が役に立ったらシェア