解約通知を最初に受け取ったときのことを、今でも覚えています。個人でアプリを運用していると、ダウンロード数のグラフばかりを追ってしまいがちですが、本当に収益を左右するのは「一度離れた人が戻ってくるかどうか」でした。
サブスクリプションアプリでは、獲得コスト(CAC)を下げる努力と同じ重み——いえ、それ以上の重みで、既存ユーザーをつなぎとめる設計が効いてきます。月間解約率(Churn Rate)がわずか1%改善するだけで、LTV(顧客生涯価値)は数十%単位で伸びることも珍しくありません。守りの一手が、そのまま次の攻めの余力になります。
以下では、RevenueCat の解約防止機能を、Rork Max で作ったアプリに実装する手順を追っていきます。私自身が個人開発で試してきた順序——設定だけで効く即効性の高い施策から、データを積み上げて効果を測る中長期の設計まで——を、実際に手を動かす流れで並べました。
RevenueCat の解約防止エコシステムを理解する
まず、RevenueCat が解約対策に提供するツールを整理しておきましょう。
Offerings(オファリング)と Packages(パッケージ)
サブスクリプションの種類(月額・年額・生涯)を管理する単位です。Win-Back Offers もここで定義します。
Promotional Offers(プロモーショナルオファー)
Apple StoreKit 2 / Google Play Billing に対応した割引オファーです。既存・元契約者向けの特別価格を提示できます。
Offer Codes(オファーコード)
SNSキャンペーン・メルマガ・インフルエンサー経由で配布する無料/割引コード。一定期間のトライアルや割引サブスクリプションを提供できます。
Grace Period(グレースピリオド)
決済失敗時に一定期間(iOS: 最大16日、Android: 最大30日)サービス継続を許可する機能。Involuntary Churn(意図しない解約)を削減します。
Webhooks & Event Streams
「試用期間終了」「更新失敗」「解約」などのライフサイクルイベントをバックエンドで受信し、適切なフォローアップアクションを自動化できます。
オファーコードの実装
App Store Connect でオファーコードを作成する
App Store Connect の「App内課金」>「サブスクリプション」から、対象サブスクリプションを選択し、「オファーコード」タブで作成します。
- オファータイプ: 無料期間、割引、固定価格(Pay Up Front)の3種類
- コード数: 最大1,000コード/オファー(バッチ生成可能)
- 期間制限: 終了日を設定して期間限定キャンペーンに活用
Rork Max でコード入力UIを実装する
import Purchases, { PurchasesStoreProduct } from 'react-native-purchases';
// オファーコード入力画面
export const RedeemOfferCodeScreen = () => {
const [code, setCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRedeem = async () => {
setIsLoading(true);
setError(null);
try {
// iOS: presentCodeRedemptionSheet() で Apple 標準UIを表示
await Purchases.presentCodeRedemptionSheet();
// コード適用後に購読ステータスを更新
const customerInfo = await Purchases.getCustomerInfo();
const isPremium = customerInfo.entitlements.active['premium'] !== undefined;
if (isPremium) {
// 成功 → プレミアム機能アンロック
navigation.replace('PremiumHome');
}
} catch (e: any) {
setError('コードの適用中にエラーが発生しました。もう一度お試しください。');
} finally {
setIsLoading(false);
}
};
// Android: カスタムコード入力UIを実装
const handleRedeemAndroid = async () => {
setIsLoading(true);
try {
// Google Play のコード適用API
const offerings = await Purchases.getOfferings();
// Android は Google Play の標準フローでコードを適用
} catch (e) {
setError('Android コードの適用に失敗しました。');
} finally {
setIsLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>特別オファーコードを入力</Text>
<Text style={styles.description}>
メルマガやSNSで受け取ったコードを入力して、特別割引を適用してください。
</Text>
<TouchableOpacity
style={[styles.redeemButton, isLoading && styles.disabled]}
onPress={Platform.OS === 'ios' ? handleRedeem : handleRedeemAndroid}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>コードを適用する</Text>
)}
</TouchableOpacity>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
Win-Back Offers(復帰オファー)の設計と実装
Win-Back Offers は、かつてサブスクリプションを保持していたが解約したユーザーを対象にした特別割引オファーです。Apple StoreKit 2 に対応した強力な機能です。
App Store Connect での設定
- 対象サブスクリプション → 「サブスクリプションオファー」→「Win-Backオファー」を作成
- 以下のパラメータを設定します:
- 資格: 過去に購読していたユーザーのみ
- 割引タイプ: 無料期間(2週間〜1ヶ月が効果的)または大幅割引(50〜70%オフ)
- 表示タイミング: 解約から30日後〜90日後が最も有効
RevenueCat 経由で Win-Back Offer を提示する
import Purchases, {
PurchasesOfferings,
PurchasesPackage,
PromotionalOffer
} from 'react-native-purchases';
// Win-Back対象ユーザーかどうかを確認
const checkWinBackEligibility = async (): Promise<boolean> => {
const customerInfo = await Purchases.getCustomerInfo();
// 現在のサブスクリプションがない & 過去に購読歴がある
const hasActiveSubscription = Object.keys(
customerInfo.entitlements.active
).length > 0;
const hadPreviousSubscription =
customerInfo.allPurchasedProductIdentifiers.includes('premium_monthly') ||
customerInfo.allPurchasedProductIdentifiers.includes('premium_yearly');
return !hasActiveSubscription && hadPreviousSubscription;
};
// Win-Back Offer を取得して提示
const presentWinBackOffer = async () => {
try {
const offerings = await Purchases.getOfferings();
const winBackOffering = offerings.all['win_back'];
if (!winBackOffering) {
// Win-Back Offering が設定されていない場合は通常プランを表示
presentStandardOffering();
return;
}
const monthlyPackage = winBackOffering.monthly;
if (!monthlyPackage) return;
// プロモーショナルオファーを取得
const promotionalOffer = await Purchases.getPromotionalOffer(
monthlyPackage.product,
monthlyPackage.product.discounts?.[0]
);
if (promotionalOffer) {
// Win-Back 専用の割引価格で購入フローを開始
const { customerInfo } = await Purchases.purchaseDiscountedPackage(
monthlyPackage,
promotionalOffer
);
if (customerInfo.entitlements.active['premium']) {
// 購読再開成功 → お帰りウェルカム画面へ
navigation.replace('WelcomeBack');
}
}
} catch (e: any) {
if (!e.userCancelled) {
console.error('Win-Back purchase error:', e);
}
}
};
Win-Back メール・プッシュ通知の設計
RevenueCat の Webhook で EXPIRATION イベントを受信したら、バックエンドで以下のシーケンスを実行します。
// バックエンド (Hono on Cloudflare Workers)
import { Hono } from 'hono';
const app = new Hono();
app.post('/webhooks/revenuecat', async (c) => {
const body = await c.req.json();
const { event } = body;
switch (event.type) {
case 'EXPIRATION': {
const userId = event.app_user_id;
const productId = event.product_id;
// 解約後 1日: 「ご解約のお知らせ」メール(感謝トーン)
await scheduleEmail(userId, 'expiration_day1', { productId });
// 解約後 7日: 「フィードバックお願い」メール + 短期割引オファー
await scheduleEmail(userId, 'expiration_day7', {
offerCode: 'COMEBACK30', // 30%オフ1ヶ月
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
});
// 解約後 30日: Win-Back Offer 案内プッシュ通知
await schedulePushNotification(userId, 'winback_day30');
// 解約後 90日: 最終オファー(最大割引)
await scheduleEmail(userId, 'expiration_day90', {
offerCode: 'WINBACK50', // 50%オフ2ヶ月
isLastOffer: true
});
break;
}
case 'RENEWAL': {
// 更新成功 → スケジュールされたWin-Backメールをキャンセル
await cancelScheduledEmails(event.app_user_id);
break;
}
}
return c.json({ received: true });
});
Grace Period(グレースピリオド)で Involuntary Churn を削減する
決済失敗による意図しない解約(Involuntary Churn)は、サブスクリプション収益の8〜15%を占めるとされています。Grace Period を設定することで、ユーザーに決済情報更新の猶予を与えられます。
設定手順
iOS: App Store Connect → App情報 → 「グレースピリオド」を有効化(最大16日)
Android: Google Play Console → 収益化 → 定期購入設定 → グレースピリオドを有効化(最大30日)
アプリ内でグレースピリオドを検出する
import Purchases, { CustomerInfo } from 'react-native-purchases';
const checkSubscriptionStatus = async () => {
const customerInfo = await Purchases.getCustomerInfo();
const premiumEntitlement = customerInfo.entitlements.all['premium'];
if (!premiumEntitlement) {
// 購読なし
return { status: 'free' };
}
const isActive = premiumEntitlement.isActive;
const willRenew = premiumEntitlement.willRenew;
const billingIssue = premiumEntitlement.billingIssueDetectedAt;
const gracePeriodExpiry = premiumEntitlement.gracePeriodExpiresDate;
if (isActive && billingIssue) {
// グレースピリオド中 → 決済更新を優しく促す
return {
status: 'grace_period',
expiresAt: gracePeriodExpiry,
daysRemaining: Math.ceil(
(new Date(gracePeriodExpiry!).getTime() - Date.now()) /
(1000 * 60 * 60 * 24)
)
};
}
if (isActive && !willRenew) {
// 解約予告(期間終了後に失効)
return {
status: 'cancelling',
expiresAt: premiumEntitlement.expirationDate
};
}
if (isActive) {
return { status: 'active' };
}
return { status: 'expired' };
};
// グレースピリオドバナー表示コンポーネント
const GracePeriodBanner = ({ daysRemaining }: { daysRemaining: number }) => (
<View style={styles.warningBanner}>
<Ionicons name="warning-outline" size={20} color="#FF9500" />
<Text style={styles.warningText}>
お支払い情報の更新が必要です。あと{daysRemaining}日でサービスが一時停止されます。
</Text>
<TouchableOpacity
onPress={() => Linking.openURL('https://apps.apple.com/account/subscriptions')}
>
<Text style={styles.updateLink}>今すぐ更新</Text>
</TouchableOpacity>
</View>
);
Cancellation Survey(解約理由アンケート)で離脱を防ぐ
解約ボタンを押したユーザーに理由を尋ね、理由に応じた引き留めオファーを提示することで、解約率を大幅に削減できます。
解約フローの設計
type CancellationReason =
| 'too_expensive'
| 'not_using_enough'
| 'missing_features'
| 'technical_issues'
| 'temporary_break'
| 'other';
interface CancellationOffer {
reason: CancellationReason;
offerType: 'pause' | 'discount' | 'downgrade' | 'feedback';
discount?: number; // パーセント
pauseDuration?: number; // 日数
}
const CANCELLATION_OFFERS: Record<CancellationReason, CancellationOffer> = {
too_expensive: {
reason: 'too_expensive',
offerType: 'discount',
discount: 50,
},
not_using_enough: {
reason: 'not_using_enough',
offerType: 'pause',
pauseDuration: 30,
},
missing_features: {
reason: 'missing_features',
offerType: 'feedback',
},
technical_issues: {
reason: 'technical_issues',
offerType: 'feedback',
},
temporary_break: {
reason: 'temporary_break',
offerType: 'pause',
pauseDuration: 30,
},
other: {
reason: 'other',
offerType: 'discount',
discount: 30,
},
};
// 解約フロースクリーン
export const CancellationFlowScreen = () => {
const [step, setStep] = useState<'survey' | 'offer' | 'confirmed'>('survey');
const [selectedReason, setSelectedReason] = useState<CancellationReason | null>(null);
const handleReasonSelect = (reason: CancellationReason) => {
setSelectedReason(reason);
setStep('offer');
};
const handleAcceptOffer = async () => {
if (!selectedReason) return;
const offer = CANCELLATION_OFFERS[selectedReason];
if (offer.offerType === 'discount') {
// 割引オファーを適用して解約をキャンセル
await applyRetentionDiscount(offer.discount!);
} else if (offer.offerType === 'pause') {
// サブスクリプションを一時停止(RevenueCat Pausing)
await pauseSubscription(offer.pauseDuration!);
}
navigation.replace('RetentionSuccess', { offerType: offer.offerType });
};
const handleConfirmCancellation = async () => {
// 解約処理: App Store / Google Play の設定画面を開く
if (Platform.OS === 'ios') {
await Linking.openURL('https://apps.apple.com/account/subscriptions');
} else {
await Linking.openURL(
'https://play.google.com/store/account/subscriptions'
);
}
setStep('confirmed');
};
if (step === 'survey') {
return (
<View style={styles.container}>
<Text style={styles.title}>解約の理由をお聞かせください</Text>
{(
[
{ key: 'too_expensive', label: '料金が高い' },
{ key: 'not_using_enough', label: 'あまり使っていない' },
{ key: 'missing_features', label: '欲しい機能がない' },
{ key: 'technical_issues', label: '技術的な問題がある' },
{ key: 'temporary_break', label: 'しばらく休憩したい' },
{ key: 'other', label: 'その他' },
] as { key: CancellationReason; label: string }[]
).map(({ key, label }) => (
<TouchableOpacity
key={key}
style={styles.reasonButton}
onPress={() => handleReasonSelect(key)}
>
<Text style={styles.reasonText}>{label}</Text>
<Ionicons name="chevron-forward" size={16} color="#999" />
</TouchableOpacity>
))}
</View>
);
}
if (step === 'offer') {
const offer = CANCELLATION_OFFERS[selectedReason!];
return (
<RetentionOfferCard
offer={offer}
onAccept={handleAcceptOffer}
onDecline={handleConfirmCancellation}
/>
);
}
return <CancellationConfirmedScreen />;
};
施策の優先順位 — アプリの状況から逆算する
ここまで多くの機能を紹介してきました。全てを一度に入れる必要はありません。むしろ、アプリの現状によって「最初に効く一手」は変わります。
解約には二つの顔があります。決済失敗のような**意図しない解約(Involuntary Churn)と、ユーザーが自ら選ぶ意図的な解約(Voluntary Churn)**です。どちらが多いかで、打つべき手はまるで違ってきます。
個人でアプリを運用してきた中で痛感したのは、「離脱の理由を見ないまま引き留め策を足しても、労力が空回りする」ということでした。まず自分のアプリがどちらの解約に多く悩まされているかを見極める。順番を間違えなければ、少ない工数で守りが固まります。
| アプリの状況 | 最優先で入れる施策 | 理由 |
| 決済失敗による解約が目立つ(更新失敗ログが多い) | Grace Period | 設定だけで即効。意図しない離脱を数十%単位で回収できる |
| 解約理由が分からない | Cancellation Survey | まずデータを集める。理由が見えないと引き留めもオファーも当て推量になる |
| 解約直前の離脱が多い | 引き留めオファー(Promotional Offer) | 価格が理由の層に一時割引が効く。ただしアンケート結果に基づいて出す |
| 一度離れた元契約者が多い | Win-Back Offers | 30日以内なら復帰の余地が大きい。休眠を掘り起こす |
| 収益の全体像が見えない | LTV ダッシュボード | コホート別に見えないと、施策の良し悪しを判断できない |
一つ加えるたびに、次の一手が見えやすくなります。Grace Period でログが減れば、残る解約は「意図的な離脱」に絞り込まれます。アンケートで理由が集まれば、オファーの中身が具体的になります。焦って全部入れるより、一段ずつ足場を確かめるほうが、結局は速いのです。
Win-Back の効果をホールドアウトで測る
Win-Back キャンペーンを打つと、必ず何人かは戻ってきます。問題は、その復帰が「オファーのおかげ」なのか「放っておいても戻ってきた人」なのかを、多くの実装が区別できていない点です。復帰者を全てオファーの功績に数えると、効果を過大評価してしまいます。
ここで有効なのが**ホールドアウト(対照群)**です。復帰対象のユーザーを二つに分け、片方にだけオファーを送り、もう片方には何も送りません。両群の復帰率の差こそが、オファーが生んだ「増分(インクリメンタルリフト)」です。
// 復帰対象ユーザーを決定論的に2群へ分割する
// user.id のハッシュ下位ビットで振り分け、再実行しても群が変わらないようにする
const assignWinbackGroup = (userId: string): 'treatment' | 'holdout' => {
// 単純だが安定したハッシュ(FNV-1a の簡易版)
let hash = 2166136261;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
// 15% をホールドアウトに回す(残り85%へオファー配信)
return (hash >>> 0) % 100 < 15 ? 'holdout' : 'treatment';
};
// キャンペーン実行時
const runWinbackCampaign = async (lapsedUsers: LapsedUser[]) => {
for (const user of lapsedUsers) {
const group = assignWinbackGroup(user.id);
// 群を記録(後で復帰率を突き合わせるため)
await recordCampaignAssignment({
userId: user.id,
campaign: 'winback_2026q3',
group,
assignedAt: new Date().toISOString(),
});
// treatment 群にだけオファーを送る
if (group === 'treatment') {
await sendWinbackOffer(user);
}
}
};
復帰したかどうかは、RevenueCat の Webhook で判定します。休眠後の再課金を示すイベントを拾い、両群の復帰率を出したうえで、増分だけを効果として報告します。
// 増分リフトの算出
const measureIncrementalLift = (results: {
treatmentReactivated: number;
treatmentTotal: number;
holdoutReactivated: number;
holdoutTotal: number;
}) => {
const treatmentRate = results.treatmentReactivated / results.treatmentTotal;
const holdoutRate = results.holdoutReactivated / results.holdoutTotal;
// 純粋にオファーが押し上げた復帰率
const incrementalLift = treatmentRate - holdoutRate;
return {
treatmentRate: (treatmentRate * 100).toFixed(1) + '%',
holdoutRate: (holdoutRate * 100).toFixed(1) + '%',
incrementalLift: (incrementalLift * 100).toFixed(1) + 'pt',
// 割引原資に見合うかは、この増分 × LTV で判断する
};
};
ホールドアウトを置くと、短期的には「送れば戻ったかもしれない15%」を手放すことになります。もったいなく感じます。けれど、この15%が「割引を配る価値が本当にあるのか」を教えてくれます。特に元契約者への割引は原資を伴います。増分で見て初めて、その原資が投資なのか浪費なのかが分かります。数字を正直に見る姿勢が、長い運用では効いてきます。
LTV 分析ダッシュボードの構築
RevenueCat のデータを活用して、コホート別 LTV・チャーンレート・MRR(月次経常収益)をトラッキングします。
RevenueCat Charts API でデータを取得する
// バックエンド: RevenueCat REST API からデータ集計
const fetchRevenueCatMetrics = async () => {
const headers = {
Authorization: `Bearer ${process.env.REVENUECAT_SECRET_KEY}`,
'Content-Type': 'application/json',
};
// MRR(月次経常収益)
const mrrResponse = await fetch(
'https://api.revenuecat.com/v1/charts/mrr',
{ headers }
);
const mrrData = await mrrResponse.json();
// チャーンレート
const churnResponse = await fetch(
'https://api.revenuecat.com/v1/charts/churned_subscribers',
{ headers }
);
const churnData = await churnResponse.json();
// コンバージョン率(Trial → Paid)
const conversionResponse = await fetch(
'https://api.revenuecat.com/v1/charts/trial_conversion',
{ headers }
);
const conversionData = await conversionResponse.json();
return { mrr: mrrData, churn: churnData, conversion: conversionData };
};
チャーン削減の目標設定
効果的なチャーン対策を実施した場合の期待値を整理しておきましょう。
Grace Period を有効化することで、Involuntary Churn(決済失敗による解約)を平均30〜50%削減できます。
Cancellation Survey + 引き留めオファーにより、Voluntary Churn(意図的な解約)の10〜20%を防止できます。
Win-Back Campaigns では、解約後30日以内のユーザーで10〜25%が復帰するケースが報告されています。
これらを組み合わせると、月間チャーンレートが3%のアプリが2%台前半まで低下し、12ヶ月後のユーザー残存率が約12%向上します。
全体を振り返って:チャーン対策は「収益の守り」
新規ユーザーを増やすことも大切ですが、既にいる人を守る一手は、費用対効果の面でとても報われやすい領域です。ここで紹介した施策は、一度に全部入れる必要はありません。
まずは Grace Period を有効化してください。設定は数分で終わり、意図しない解約をすぐに減らせます。次に Cancellation Survey で解約理由を集め、データがたまってから理由に合わせた引き留めオファーを設計します。そして Win-Back でいったん離れた人の復帰を狙う——この順番なら、少ない工数で守りが積み上がっていきます。
Win-Back の効果は、必ずホールドアウトで確かめてください。増分で見る癖がつくと、割引の原資を「浪費」ではなく「投資」として扱えるようになります。
RevenueCat は課金まわりの複雑さを引き受けてくれます。その分、私たちは体験そのものに集中できます。Rork Max で作ったアプリに一つずつ組み込みながら、無理のない収益基盤を育てていければと思います。お読みいただき、ありがとうございました。