2013年から個人開発でアプリを運用してきて、最初の数年は「新規ダウンロードを増やせば収益は伸びる」と素直に信じていました。累計5,000万ダウンロードを越えたあたりから、ようやく腑に落ちたのは、新規よりも解約を1%減らすほうが、収益に与えるインパクトが桁違いに大きいということです。
サブスクリプションアプリの収益は、新規獲得と解約防止の積で決まります。それでも個人開発者がチャーン防止を後回しにしがちなのは、仕組みを作るのが難しそうに見えるからだと思います。私自身、長らく AdMob 中心の広告収益モデルで運用してきましたが、近年は壁紙アプリの一部にサブスクリプション課金を組み込み、解約予兆の検知と自動ウィンバックを RevenueCat と Antigravity が生成したコードで運用し始めました。
実際にやってみると、思っていたよりずっとシンプルに実装できます。その全工程を、動くコードと運用上の判断基準とともに紹介します。読者のサブスクリプション事業のリテンション改善に、そのまま使える形を目指しました。
チャーン防止パイプラインの全体設計
まず全体の設計を把握しておきましょう。
[RevenueCat Webhook]
↓ イベント送信(解約・更新失敗・試用終了など)
[Cloudflare Workers] — Webhook受信・バリデーション
↓
[Supabase] — イベント蓄積・ユーザー行動DB
↓
[チャーンスコア計算] — AI(Antigravity)がリスクを評価
↓
[ウィンバックシーケンス] — Resend でメール配信
↓
[効果測定] — 再サブスク率をSupabaseで追跡
シンプルですが、それぞれのステップで考慮すべき点があります。順番に解説します。
5,000万DLの個人開発から見えた、チャーンの不都合な数字
なぜ「チャーン1%削減」が「新規3%増加」より効くのか、数式で確認しておきます。
ある月の MRR(月次経常収益)を $M$、月次チャーン率を $c$、月次成長率(新規 - チャーン)を $g$ とすると、サブスクリプションの長期的な売上は、年あたり概ね次のように振る舞います。
- 年次売上 $\approx M \times 12 \times \frac{1}{c}$ (ユーザー1人あたりの平均寿命がチャーン率の逆数)
- チャーン率 $c$ が 5% → 4% に下がると、ユーザー平均寿命が 20 ヶ月 → 25 ヶ月 に伸び、25% のLTV改善
- 一方、新規獲得が 3% 増えてもチャーン率が同じなら、LTVは変わらず単に瞬間的な売上が 3% 増えるだけ
私のアプリでも、新規流入はマーケティング費に比例して伸びますが、チャーン率を 1pt(パーセンテージポイント)下げる施策は、いったん仕組みを作ればそのまま全月に効き続けます。複利で効くことが、新規施策との根本的な違いです。
参考までに、個人開発でサブスクリプションを試した最初の3ヶ月、何の対策も入れなかった月次チャーン率は 約 8.2% でした。本稿のパイプラインを段階的に入れ、HIGH リスクユーザーへの即時メール → 48時間後の50%オフ・オファー → 7日後の最終ウィンバック、を組んだ結果、チャーン率は 5.4% まで落ち、LTV は概ね 約1.5倍 に改善しました。これはほぼ無料インフラ(Cloudflare Workers + Supabase 無料枠 + Resend 月3,000通無料)の範囲内で達成できる数字です。
Step 1: RevenueCat Webhook の設定とイベント種別
RevenueCat Dashboard → 「Integrations」→「Webhooks」から Webhook URL を登録します。
チャーン防止で特に重要なイベント種別:
CANCELLATION: ユーザーが次回更新をキャンセルした(まだ解約ではない)
EXPIRATION: サブスクリプションが実際に切れた
BILLING_ISSUE: 決済失敗(クレジットカード期限切れなど)
PRODUCT_CHANGE: プランをダウングレードした
RENEWAL: 更新成功(チャーンしなかった)
CANCELLATION の時点でウィンバック施策を打てれば、ユーザーがまだアクティブな状態でリテンションを試みられます。EXPIRATION まで待つと手遅れになることが多いというのが、現場感覚です。
Step 2: Webhook 受信エンドポイント(Cloudflare Workers)
// src/webhook.ts
import { createClient } from "@supabase/supabase-js";
interface RevenueCatWebhookEvent {
event: {
id: string;
type: string;
app_user_id: string;
product_id: string;
period_type: string;
purchased_at_ms: number;
expiration_at_ms: number;
environment: "SANDBOX" | "PRODUCTION";
cancel_reason?: string;
};
api_version: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
// RevenueCat の Authorization ヘッダーを検証
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${env.REVENUECAT_WEBHOOK_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const body = await request.json() as RevenueCatWebhookEvent;
const event = body.event;
// Sandboxイベントは本番DBに書かない
if (event.environment === "SANDBOX" && env.ENVIRONMENT === "production") {
return new Response("OK (sandbox ignored)", { status: 200 });
}
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
// 冪等性: event.id を一意制約で持たせ、重複インサートを DB レベルで弾く
const { error: insertError } = await supabase.from("subscription_events").insert({
event_id: event.id,
user_id: event.app_user_id,
event_type: event.type,
product_id: event.product_id,
cancel_reason: event.cancel_reason ?? null,
expired_at: event.expiration_at_ms
? new Date(event.expiration_at_ms).toISOString()
: null,
created_at: new Date().toISOString(),
});
if (insertError && insertError.code === "23505") {
// duplicate key: すでに処理済みのイベントなので 200 を返す
return new Response("OK (duplicate)", { status: 200 });
}
// チャーンリスクイベントをキューに追加
const CHURN_RISK_EVENTS = ["CANCELLATION", "BILLING_ISSUE", "PRODUCT_CHANGE"];
if (CHURN_RISK_EVENTS.includes(event.type)) {
await env.CHURN_RISK_QUEUE.send({
user_id: event.app_user_id,
event_type: event.type,
cancel_reason: event.cancel_reason,
expires_at: event.expiration_at_ms,
});
}
return new Response("OK", { status: 200 });
},
};
Step 3: チャーンスコア計算(AI 分析)
Queue ワーカーでチャーンスコアを計算します。Antigravity が生成したコードをベースに、ユーザーの行動パターンからリスクを評価します。
// src/churn-scorer.ts
import { createClient } from "@supabase/supabase-js";
interface ChurnInput {
user_id: string;
event_type: string;
cancel_reason?: string;
expires_at?: number;
}
interface ChurnScore {
score: number; // 0.0 - 1.0(高いほどリスク大)
risk_level: "LOW" | "MEDIUM" | "HIGH";
win_back_strategy: string;
recommended_action: string;
}
async function fetchUserBehavior(
supabase: ReturnType<typeof createClient>,
user_id: string,
): Promise<object> {
// 過去のイベント履歴を取得
const { data: events } = await supabase
.from("subscription_events")
.select("event_type, created_at")
.eq("user_id", user_id)
.order("created_at", { ascending: false })
.limit(50);
// アプリ利用データ(別途実装が必要)
const { data: usage } = await supabase
.from("app_usage")
.select("session_count, last_active_at, feature_usage")
.eq("user_id", user_id)
.single();
return {
event_history: events ?? [],
session_count_last_30d: usage?.session_count ?? 0,
last_active_at: usage?.last_active_at,
feature_usage: usage?.feature_usage ?? {},
};
}
export async function calculateChurnScore(
input: ChurnInput,
env: Env,
): Promise<ChurnScore> {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
const behavior = await fetchUserBehavior(supabase, input.user_id);
// AI(Google Gemini Flash)でチャーンスコアを計算
const prompt = `
あなたはサブスクリプションアプリのチャーン分析専門家です。
以下のユーザー行動データからチャーンリスクスコアを算出してください。
【トリガーイベント】
- イベント種別: ${input.event_type}
- キャンセル理由: ${input.cancel_reason ?? "不明"}
- サブスク期限: ${input.expires_at ? new Date(input.expires_at).toLocaleDateString("ja-JP") : "不明"}
【ユーザー行動データ】
${JSON.stringify(behavior, null, 2)}
以下のJSON形式で回答してください:
{
"score": 0.0〜1.0の数値(高いほどチャーンリスク大),
"risk_level": "LOW" または "MEDIUM" または "HIGH",
"win_back_strategy": "推奨するウィンバック戦略(1文)",
"recommended_action": "具体的なアクション(割引・特典・連絡タイミング等)"
}
評価基準:
- キャンセル理由が「高すぎる」→ 価格敏感(割引オファーが有効)
- 直近30日のセッション数が少ない → 価値を感じていない(機能紹介が有効)
- 過去に一度キャンセル後に復帰している → ウィンバック成功率が高い
- 課金失敗 → 決済情報更新の案内が最優先
`;
const response = await fetch(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": env.GEMINI_API_KEY,
},
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
}),
},
);
const data = await response.json() as { candidates: Array<{ content: { parts: Array<{ text: string }> } }> };
const text = data.candidates[0].content.parts[0].text;
const start = text.indexOf("{");
const end = text.lastIndexOf("}") + 1;
return JSON.parse(text.slice(start, end)) as ChurnScore;
}
fetchUserBehavior で取り出している session_count と feature_usage は、私の経験上、スコアリングの精度を最も大きく左右する2つの変数です。直近30日のセッション数がゼロに近く、かつ高単価機能(書き出し・PDF出力・無広告化など)を一度も使っていないユーザーは、HIGH 判定で間違いません。逆に、毎日のように開いているのにキャンセルしているユーザーは、ほぼ確実に価格不満なので、価格訴求より割引オファーを優先します。
Step 4: ウィンバックシーケンス(Resend メール配信)
チャーンスコアに応じて異なるメールシーケンスを送信します。
// src/winback-emailer.ts
import { Resend } from "resend";
interface WinbackContext {
user_id: string;
email: string;
name: string;
churn_score: ChurnScore;
app_name: string;
cancel_reason?: string;
}
const EMAIL_SEQUENCES: Record<string, Array<{ delay_hours: number; template: string }>> = {
// 高リスク: 即時 + 72時間以内に割引オファー
HIGH: [
{ delay_hours: 0, template: "high_risk_immediate" },
{ delay_hours: 48, template: "high_risk_discount_offer" },
{ delay_hours: 168, template: "final_winback" }, // 7日後
],
// 中リスク: 24時間後に価値再訴求
MEDIUM: [
{ delay_hours: 24, template: "medium_risk_value_highlight" },
{ delay_hours: 120, template: "medium_risk_tips" }, // 5日後
],
// 低リスク: 1週間後にソフトなフォロー
LOW: [
{ delay_hours: 168, template: "low_risk_soft_followup" },
],
};
async function sendWinbackEmail(
context: WinbackContext,
template: string,
env: Env,
): Promise<void> {
const resend = new Resend(env.RESEND_API_KEY);
const templates: Record<string, { subject: string; html: string }> = {
high_risk_immediate: {
subject: `${context.app_name} をご利用いただきありがとうございました`,
html: `
<p>${context.name ?? "ユーザー"}様</p>
<p>サブスクリプションのキャンセルを承りました。</p>
<p>もし価格面でのご不安がございましたら、ぜひご相談ください。
お客様のご状況に合わせたプランをご提案できる場合があります。</p>
<p><a href="${env.SUPPORT_URL}">サポートに連絡する</a></p>
`,
},
high_risk_discount_offer: {
subject: `【特別オファー】${context.app_name} を特別価格でご継続いただけます`,
html: `
<p>${context.name ?? "ユーザー"}様</p>
<p>ご継続をお考えの方に、特別価格をご用意しました。</p>
<p><strong>今だけ初月50%オフ</strong>でご利用いただけます。</p>
<p><a href="${env.RESUBSCRIBE_URL}?discount=50&user=${context.user_id}">
特別価格で続ける
</a></p>
<p>このオファーは${new Date(Date.now() + 72 * 60 * 60 * 1000).toLocaleDateString("ja-JP")}まで有効です。</p>
`,
},
};
const emailContent = templates[template];
if (!emailContent) return;
await resend.emails.send({
from: `${context.app_name} <noreply@${env.EMAIL_DOMAIN}>`,
to: context.email,
subject: emailContent.subject,
html: emailContent.html,
});
}
export async function executeWinbackSequence(
context: WinbackContext,
env: Env,
): Promise<void> {
const sequence = EMAIL_SEQUENCES[context.churn_score.risk_level] ?? [];
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
// 直近30日以内に既にシーケンス実行済みのユーザーはスキップ(スパム判定対策)
const { count } = await supabase
.from("winback_schedules")
.select("*", { count: "exact", head: true })
.eq("user_id", context.user_id)
.gte("created_at", new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString());
if ((count ?? 0) > 0) {
console.log(`Skipping winback for ${context.user_id} — sequence already active`);
return;
}
for (const step of sequence) {
const sendAt = new Date(Date.now() + step.delay_hours * 60 * 60 * 1000);
if (step.delay_hours === 0) {
// 即時送信
await sendWinbackEmail(context, step.template, env);
} else {
// スケジュール送信 (Cloudflare Queues の遅延送信機能を使用)
await env.WINBACK_EMAIL_QUEUE.send(
{
user_id: context.user_id,
email: context.email,
name: context.name,
template: step.template,
app_name: context.app_name,
},
{ delaySeconds: step.delay_hours * 3600 },
);
}
// スケジュールをDBに記録
await supabase.from("winback_schedules").insert({
user_id: context.user_id,
template: step.template,
scheduled_at: sendAt.toISOString(),
status: step.delay_hours === 0 ? "sent" : "scheduled",
});
}
}
Step 5: 効果測定とダッシュボード
ウィンバックの成否を測定するには、再サブスクリプションイベント(RevenueCat の INITIAL_PURCHASE または RENEWAL )を追跡します。
// src/winback-analytics.ts
async function calculateWinbackEffectiveness(
supabase: ReturnType<typeof createClient>,
days: number = 30,
): Promise<{
total_churn_events: number;
winback_sent: number;
resubscribed: number;
winback_rate: number;
revenue_recovered_usd: number;
}> {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
// チャーンイベント数
const { count: total_churn } = await supabase
.from("subscription_events")
.select("*", { count: "exact", head: true })
.in("event_type", ["CANCELLATION", "EXPIRATION"])
.gte("created_at", since);
// ウィンバックメール送信数
const { count: winback_sent } = await supabase
.from("winback_schedules")
.select("*", { count: "exact", head: true })
.eq("status", "sent")
.gte("created_at", since);
// ウィンバック後に再サブスクしたユーザー数
const { data: resubscribed_users } = await supabase.rpc(
"count_winback_resubscriptions",
{ since_date: since },
);
const resubscribed = resubscribed_users?.[0]?.count ?? 0;
const winback_rate = winback_sent > 0 ? resubscribed / winback_sent : 0;
return {
total_churn_events: total_churn ?? 0,
winback_sent: winback_sent ?? 0,
resubscribed,
winback_rate,
revenue_recovered_usd: resubscribed * 4.99, // 平均プラン価格で計算
};
}
ウィンバック施策が機能しているかは、winback_rate を月次でモニタリングするのが分かりやすいです。私が運用している壁紙アプリでは、HIGH リスクユーザーへの即時メールの転換率は概ね 8〜12%、48時間後の割引オファーは 3〜6%、合計で送信ユーザーの 約10% が再サブスクしています。ベンチマークとしては、月間 100 件のチャーンに対しておおむね 10 件、$5/月プランなら年間 $600 の回収という規模感です。
公式ドキュメントには書かれていない、本番運用7つの落とし穴
ここからは、ドキュメント通りに作ったあと、本番運用で実際に踏み抜いた7つの罠を、対処法とあわせて整理します。個人開発で運用しているサブスクリプションアプリで、ひとつずつ実際に起きた事象です。
1. Sandbox イベントが本番 KPI を歪める
App Store Connect のサンドボックステスターでサブスクをいじっていると、SANDBOX 環境の CANCELLATION が大量に流れ込みます。これを本番イベントとして集計すると、見せかけのチャーン率が急騰します。event.environment でのフィルタは必須です。
if (event.environment === "SANDBOX" && env.ENVIRONMENT === "production") {
return new Response("OK (sandbox ignored)", { status: 200 });
}
2. Webhook 重複配信で同じユーザーに3通送る
RevenueCat は配信失敗時に再送します。event_id を一意制約として subscription_events テーブルに持たせるのが鉄板の対処です。私は最初これを忘れて、同じユーザーに同じ「特別オファー」メールが3通届くという、もっとも避けたい体験を1人のお客様に提供してしまいました。
ALTER TABLE subscription_events
ADD CONSTRAINT subscription_events_event_id_key UNIQUE (event_id);
3. BILLING_ISSUE をウィンバック扱いすると逆効果
クレジットカードが期限切れになっただけのユーザーに「初月50%オフ」のオファーを送ると、「自分は解約していないのに何の話だ」となります。BILLING_ISSUE は決済情報更新の案内に専用ルートで分岐すべきです。
4. キャンセル直後に動くと「しつこい」と感じられる境界線
CANCELLATION から 3〜6時間後 に最初のメールを送るのが、私の運用での体感的なスイートスポットです。即時(0時間)だと、まだキャンセル画面を閉じる前にメールが届くことすらあり、不気味さを与えます。長すぎると検討モードが冷めます。
5. 割引オファーは2通目以降に置く
最初のメールから割引を出すと、「次回キャンセル → 割引待ち」の癖がつきます。私の実測では、1通目で割引を出した時期と、2通目(48時間後)で割引を出した時期で、長期的な ARPPU は2通目運用のほうが約15%高い という結果でした。LTV を守るには、最初は感謝、次に割引、という順序が効きます。
6. Cloudflare Queues の遅延送信は最大15分の揺れがある
delaySeconds を指定しても、Queue 側の処理タイミング次第で実際の送信は数分から最大15分遅れます。「48時間後ぴったり」を期待するのではなく、scheduled_at を DB に記録して、Cron で now() >= scheduled_at を満たすジョブを別途流すハイブリッド方式が安全です。
7. Resend のフリープランは月3,000通までで急に止まる
月3,000通の境界を越えるとメールが配信されなくなります。アラートを 2,500 通の段階で Slack に飛ばすか、Resend Pro($20/月)に切り替える判断ラインを事前に決めておくと、ある日突然ウィンバックが全滅する事故を防げます。
Antigravity でこのパイプラインをどう生成するか
最後に、Antigravity を使ってこの規模のパイプラインを生成する際の進め方を、私が実際にやっている手順で残します。
- 設計図を先に渡す: 上の全体図のアスキー図とテーブル定義を1ファイルにまとめ、Antigravity に
Plan で読ませる。コードを書かせる前に「データモデル」と「責務分割」を確定させるのが、戻り工数を減らす最大のコツです。
- Webhook → Queue → Worker をファイル単位で分割発注: 1スレッドで全部書かせると、関数間で型が合わなくなる事故が起きやすいので、
src/webhook.ts・src/churn-scorer.ts・src/winback-emailer.ts を別タスクで生成します。
- 生成後に「本番運用で起こりうる失敗を10個列挙して」と追い打ちで聞く: 重複配信・Sandbox 混入・Resend のレート制限・Queue 遅延揺れなど、実装時には見落としがちな項目をAIにも一緒に洗い出してもらいます。本稿の「7つの落とし穴」も、最初の叩き台はこの問い合わせから生まれたものを、実運用で検証して残ったものです。
- テストデータは Sandbox で発火させる: RevenueCat には Webhook テスト発火機能がありますが、実際にサブスク → キャンセルを連打して全イベント種別を一通り流したログを取ったほうが、漏れが分かります。
Antigravity が生成したコードは、そのまま本番に投入できる品質に近づいていますが、「個人開発の運用で痛い目を見た条件分岐」だけは、AI からは出てこないというのが、5,000万DL を経験して確信していることです。AI と人間の役割分担で言えば、構造の生成は AI、エッジケースの引き出しは人間、というのが現時点の落としどころだと感じています。
最後に
RevenueCat Webhook → Cloudflare Workers → AI 分析 → Resend というパイプラインは、個人開発者でも実現可能なチャーン防止インフラです。インフラ費用は、ある程度の規模まで実質ゼロで運用できます。
大切なのは、全イベントを記録すること、早期にウィンバックを試みること、AI スコアでリスクに応じた優先度をつけることの3点です。解約後のユーザーを全員同じように扱うのではなく、リスクレベルに応じた最適なメッセージを送ることが、ウィンバック率を高める鍵になります。
私自身まだ運用を磨いている途中ですが、同じく個人開発でサブスクリプション事業を育てている方の参考になれば嬉しいです。次の一歩としては、ご自身のアプリの直近30日の CANCELLATION 件数を Supabase に流し込み、まずは即時メール1通だけでもいいので、ウィンバックの最初の数字を計測するところから始めてみてください。