「App Store にリリースしてサブスクリプションを設定したのに、3か月経っても月5,000円にも届かない」
これは、Rork で課金アプリを作った個人開発者のほぼ全員が一度は直面する現実です。機能は動きます。デザインも悪くありません。価格も相場通りのはず。それでも収益は伸びません。
その原因の多くは、ユーザーが何をきっかけに課金を決断し、何をきっかけに解約するのかが見えていないことに尽きます。感覚で値段を設定し、感覚でペイウォールを置き、感覚でプッシュ通知を送っている限り、収益の改善は運頼みになってしまいます。
ここで扱うのはRork + RevenueCat + PostHog + Superwall の4つを組み合わせて、データに基づいてサブスクリプション収益を継続的に改善できるシステムの作り方を解説します。個別ツールの使い方ではなく、4つを連携させて初めて生まれる「離脱ユーザーを事前に検知し、自動介入する仕組み」に焦点を当てます。
なぜほとんどのサブスクリプションアプリは1年以内に収益が横ばいになるのか
課金アプリの収益が伸び悩む理由は、ほぼ4つのパターンに集約されます。
パターン1: ペイウォールが「置いてあるだけ」になっている
多くのアプリは、ペイウォールを1種類だけ作って設置したまま運用しません。新規ユーザーに対しても、3回目の起動ユーザーに対しても、同じ画面を見せ続けます。しかし実際には、ユーザーのアプリ利用歴・使用機能・滞在時間によって、どんなメッセージが響くかは大きく異なります。
パターン2: 解約理由がわからない
RevenueCat だけを入れていると「今月の解約数は○件」という数字は見えますが、「なぜ解約したのか」「解約した人は課金前にどんな行動をしていたのか」はわかりません。PostHog と連携させることで初めて、解約ユーザーのアプリ内行動パターンが浮き彫りになります。
パターン3: チャーン予兆を見逃している
解約は突然起きません。ほとんどの場合、解約の7〜14日前から「起動頻度の低下」「中核機能の使用減少」「課金画面の訪問増加」といった行動変化が現れます。これを自動検知して適切なタイミングでプッシュ通知を送れれば、解約の20〜30%は防げます。
パターン4: 価格とペイウォールを感覚で最適化している
価格変更やペイウォールの文言変更を「なんとなく」行い、効果測定をしていないケースは非常に多いです。Superwall を使えば、複数のペイウォールを同時並行で A/B テストしながら、どの組み合わせが最もコンバージョン率を高めるかを継続的に計測できます。
システム全体像: 4ツールの役割分担
4つのツールを組み合わせる前に、それぞれの役割を明確にしておきましょう。
- RevenueCat: サブスクリプションと課金状態の管理。iOS(StoreKit 2)・Android(Google Play Billing)の差異を吸収し、課金状態をシンプルな API で取得できます
- PostHog: ユーザーの行動をイベントとして記録し、コホート分析・ファネル分析・セッションリプレイを提供します。RevenueCat の課金状態を PostHog のユーザープロパティとして同期することで、「課金ユーザーと無料ユーザーの行動の違い」が一目でわかるようになります
- Superwall: ペイウォールのデザインと配信を管理します。A/B テストはもちろん、ユーザーの行動条件(例:5回目の起動時・特定機能を3回使った後)でペイウォールを動的に切り替えられます
- Rork: 上記3つを統合する React Native ベースのアプリ本体。Rork が生成するコードに、これらの SDK を追加していきます
この4つが連携すると、「PostHog でチャーン予兆を検知 → RevenueCat でその人の課金状態を確認 → 未課金ならSuperwall で専用ペイウォールを表示・課金中なら割引オファー付きのプッシュ通知を送信」という自動ループが作れます。
Step 1: RevenueCat のセットアップ
RevenueCat の基本的な導入手順はRevenueCat サブスクリプション完全実装ガイドで解説していますが、ここでは PostHog との連携を前提とした設定に絞って説明します。
Entitlement の設計方針
Entitlement(利用権限)は後から変更が難しいため、最初から「将来の拡張を想定した設計」が必要です。
// src/services/revenuecat.ts
import Purchases, {
PurchasesPackage,
CustomerInfo,
LOG_LEVEL
} from 'react-native-purchases';
import { Platform } from 'react-native';
// 環境変数で API キーを管理(コードに直書きしない)
const REVENUECAT_API_KEY = Platform.OS === 'ios'
? process.env.EXPO_PUBLIC_RC_IOS_KEY\!
: process.env.EXPO_PUBLIC_RC_ANDROID_KEY\!;
export async function initializeRevenueCat(userId: string): Promise<void> {
try {
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
await Purchases.configure({ apiKey: REVENUECAT_API_KEY });
// ユーザーIDを設定(PostHog の distinct_id と一致させる)
await Purchases.logIn(userId);
console.log('RevenueCat initialized for user:', userId);
} catch (error) {
// 初期化エラーは致命的ではないが記録は必要
console.error('RevenueCat initialization failed:', error);
// PostHog にエラーイベントを送信(後述)
throw error;
}
}
export async function getCustomerInfo(): Promise<CustomerInfo | null> {
try {
return await Purchases.getCustomerInfo();
} catch (error) {
console.error('Failed to fetch customer info:', error);
return null;
}
}
// 「pro」という Entitlement を持っているか確認
export async function hasProAccess(): Promise<boolean> {
const customerInfo = await getCustomerInfo();
if (\!customerInfo) return false;
return customerInfo.entitlements.active['pro'] \!== undefined;
}
ここで重要なのは、RevenueCat の logIn に渡す userId を PostHog の distinct_id と一致させることです。これが後の連携の鍵になります。
Step 2: PostHog の統合と RevenueCat との同期
PostHog の基本設定についてはPostHog プロダクトアナリティクス導入ガイドをご参照ください。ここでは RevenueCat の課金状態を PostHog に同期する実装を中心に説明します。
購読状態の同期
// src/services/analytics.ts
import PostHog from 'posthog-react-native';
import { getCustomerInfo } from './revenuecat';
const posthog = new PostHog(
process.env.EXPO_PUBLIC_POSTHOG_API_KEY\!,
{ host: 'https://app.posthog.com' }
);
// RevenueCat の課金状態を PostHog のユーザープロパティに同期
export async function syncSubscriptionStateToPostHog(userId: string): Promise<void> {
try {
const customerInfo = await getCustomerInfo();
if (\!customerInfo) return;
const hasProAccess = customerInfo.entitlements.active['pro'] \!== undefined;
const activeSubscriptions = Object.keys(customerInfo.activeSubscriptions);
const totalSpend = parseFloat(customerInfo.nonSubscriptionTransactions
.reduce((sum, tx) => sum + parseFloat(tx.price || '0'), 0)
.toFixed(2));
// PostHog のユーザープロパティを更新
posthog.identify(userId, {
// 課金状態
is_pro: hasProAccess,
active_subscriptions: activeSubscriptions,
subscription_count: activeSubscriptions.length,
total_spend_usd: totalSpend,
// 課金日時(チャーン予測に使用)
subscription_start_date: customerInfo.originalPurchaseDate,
subscription_expiry: customerInfo.latestExpirationDate,
// チャーン予測スコアの算出に使うフィールド
days_since_first_purchase: customerInfo.originalPurchaseDate
? Math.floor((Date.now() - new Date(customerInfo.originalPurchaseDate).getTime()) / (1000 * 60 * 60 * 24))
: 0,
});
// 課金ステータス変化をイベントとして記録
posthog.capture('subscription_state_synced', {
has_pro_access: hasProAccess,
active_subscriptions: activeSubscriptions,
});
} catch (error) {
console.error('Failed to sync subscription state to PostHog:', error);
posthog.capture('subscription_sync_error', { error: String(error) });
}
}
// アプリ起動時・課金完了時に呼び出す
export async function trackAppOpen(userId: string): Promise<void> {
posthog.capture('app_opened', {
timestamp: new Date().toISOString(),
});
// 課金状態の同期(バックグラウンドで実行)
syncSubscriptionStateToPostHog(userId).catch(console.error);
}
重要なイベントを漏れなく計測する
サブスクリプションのファネル分析に必要なイベントを定義します。
// src/services/tracking.ts
// アプリ内のすべてのサブスクリプション関連アクションを記録
export const TrackingEvents = {
// ペイウォール表示
PAYWALL_VIEWED: 'paywall_viewed',
PAYWALL_CLOSED: 'paywall_closed',
// 課金フロー
PURCHASE_INITIATED: 'purchase_initiated',
PURCHASE_COMPLETED: 'purchase_completed',
PURCHASE_FAILED: 'purchase_failed',
PURCHASE_RESTORED: 'purchase_restored',
// 解約関連
SUBSCRIPTION_CANCELLED: 'subscription_cancelled',
SUBSCRIPTION_EXPIRED: 'subscription_expired',
MANAGE_SUBSCRIPTION_OPENED: 'manage_subscription_opened',
// エンゲージメント(チャーン予測に使用)
CORE_FEATURE_USED: 'core_feature_used',
SETTINGS_OPENED: 'settings_opened',
HELP_OPENED: 'help_opened',
} as const;
export function trackEvent(
event: string,
properties?: Record<string, unknown>
): void {
posthog.capture(event, {
...properties,
timestamp: new Date().toISOString(),
});
}
Step 3: Superwall でペイウォールを A/B テストする
Superwall ペイウォール A/B テストガイドでは基本的な Superwall の設定方法を解説していますが、ここでは PostHog と連携させた動的なペイウォール表示を実装します。
ユーザーセグメント別のペイウォール配信
// src/services/superwall.ts
import Superwall from '@superwall/react-native-superwall';
import { getCustomerInfo } from './revenuecat';
import { trackEvent, TrackingEvents } from './tracking';
// ユーザーの行動履歴に応じてペイウォールを選択
export async function showContextualPaywall(
trigger: string,
context: {
feature: string;
sessionCount: number;
daysInstalled: number;
}
): Promise<void> {
try {
// RevenueCat で課金済みユーザーはスキップ
const customerInfo = await getCustomerInfo();
if (\!customerInfo) return;
const isPro = customerInfo.entitlements.active['pro'] \!== undefined;
if (isPro) return;
// ペイウォール表示前にイベント記録
trackEvent(TrackingEvents.PAYWALL_VIEWED, {
trigger,
feature: context.feature,
session_count: context.sessionCount,
days_installed: context.daysInstalled,
});
// Superwall のキャンペーントリガーを発火
// Superwall ダッシュボードで各トリガーに対してA/Bテストを設定する
await Superwall.shared.register({
placement: trigger,
params: context,
handler: {
onPresent(paywallInfo) {
trackEvent('paywall_presented', {
paywall_id: paywallInfo.identifier,
trigger,
});
},
onDismiss(paywallInfo, result) {
trackEvent(TrackingEvents.PAYWALL_CLOSED, {
paywall_id: paywallInfo.identifier,
close_reason: result.closedByUser ? 'user' : 'system',
});
},
onPurchase(paywallInfo) {
trackEvent(TrackingEvents.PURCHASE_COMPLETED, {
paywall_id: paywallInfo.identifier,
trigger,
});
},
onRestore(paywallInfo) {
trackEvent(TrackingEvents.PURCHASE_RESTORED, {
paywall_id: paywallInfo.identifier,
});
},
}
});
} catch (error) {
console.error('Superwall error:', error);
trackEvent('superwall_error', { error: String(error), trigger });
}
}
Step 4: チャーン予測と自動介入の実装
このセクションが、この記事で最も価値のある部分です。チャーン予兆を自動検知してプッシュ通知を送る仕組みを作ります。
チャーンリスクスコアの計算
// src/services/churnPrediction.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { trackEvent } from './tracking';
interface UserBehavior {
lastOpenDate: string;
totalOpenCount: number;
coreFeatureUsageCount: number;
daysSinceLastCoreFeatureUse: number;
paywallViewCount: number;
helpPageViewCount: number;
}
// チャーンリスクスコアを 0〜100 で計算
// スコアが高いほどチャーンリスクが高い
export function calculateChurnRiskScore(behavior: UserBehavior): number {
let score = 0;
// 最終起動からの日数(最大40点)
const daysSinceLastOpen = Math.floor(
(Date.now() - new Date(behavior.lastOpenDate).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceLastOpen >= 7) score += 40;
else if (daysSinceLastOpen >= 3) score += 20;
else if (daysSinceLastOpen >= 1) score += 5;
// コア機能の最終使用からの日数(最大30点)
if (behavior.daysSinceLastCoreFeatureUse >= 14) score += 30;
else if (behavior.daysSinceLastCoreFeatureUse >= 7) score += 15;
else if (behavior.daysSinceLastCoreFeatureUse >= 3) score += 5;
// ヘルプページ閲覧(問題を抱えているサイン・最大15点)
if (behavior.helpPageViewCount >= 3) score += 15;
else if (behavior.helpPageViewCount >= 1) score += 8;
// ペイウォール閲覧(価格に迷っているサイン・最大15点)
if (behavior.paywallViewCount >= 3) score += 15;
else if (behavior.paywallViewCount >= 1) score += 7;
return Math.min(score, 100);
}
// バックグラウンドで定期的にチャーンリスクを評価
export async function evaluateChurnRisk(userId: string): Promise<void> {
try {
const behaviorData = await AsyncStorage.getItem(`user_behavior_${userId}`);
if (\!behaviorData) return;
const behavior: UserBehavior = JSON.parse(behaviorData);
const riskScore = calculateChurnRiskScore(behavior);
// リスクスコアを PostHog に記録
trackEvent('churn_risk_evaluated', {
risk_score: riskScore,
risk_level: riskScore >= 70 ? 'high' : riskScore >= 40 ? 'medium' : 'low',
});
// 高リスクユーザーへの自動介入
if (riskScore >= 70) {
await triggerChurnInterventionNotification(userId, riskScore);
}
} catch (error) {
console.error('Churn risk evaluation failed:', error);
}
}
// チャーン介入プッシュ通知の送信
async function triggerChurnInterventionNotification(
userId: string,
riskScore: number
): Promise<void> {
// 既に送信済みの場合はスキップ(連続送信を防ぐ)
const sentKey = `churn_notification_sent_${userId}`;
const alreadySent = await AsyncStorage.getItem(sentKey);
if (alreadySent) {
const sentDate = new Date(alreadySent);
const daysSinceSent = Math.floor(
(Date.now() - sentDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceSent < 7) return; // 7日間は再送しない
}
// バックエンド API を通じてプッシュ通知を送信
// (実装例: Expo Push Notifications + Supabase Edge Functions)
try {
await fetch(process.env.EXPO_PUBLIC_API_URL + '/api/send-retention-push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
notificationType: 'churn_prevention',
riskScore,
}),
});
await AsyncStorage.setItem(sentKey, new Date().toISOString());
trackEvent('churn_intervention_notification_sent', {
risk_score: riskScore,
});
} catch (error) {
console.error('Failed to send churn notification:', error);
}
}
Step 5: アプリ起動時の統合処理
上記4つのサービスを起動時に正しい順序で初期化する処理をまとめます。
// src/hooks/useAppInitialization.ts
import { useEffect, useState } from 'react';
import { initializeRevenueCat } from '../services/revenuecat';
import { trackAppOpen, syncSubscriptionStateToPostHog } from '../services/analytics';
import { evaluateChurnRisk } from '../services/churnPrediction';
import Superwall from '@superwall/react-native-superwall';
import { useAuth } from './useAuth';
export function useAppInitialization() {
const { userId } = useAuth();
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (\!userId) return;
async function initialize() {
try {
// 1. RevenueCat 初期化(認証後に実行)
await initializeRevenueCat(userId);
// 2. Superwall 初期化
Superwall.configure({
apiKey: process.env.EXPO_PUBLIC_SUPERWALL_KEY\!,
});
// 3. PostHog へのアプリ起動イベント送信 + 課金状態同期
await trackAppOpen(userId);
// 4. チャーンリスク評価(非同期・エラーがあっても起動を妨げない)
evaluateChurnRisk(userId).catch(console.error);
setIsReady(true);
} catch (err) {
console.error('App initialization failed:', err);
setError(err instanceof Error ? err : new Error(String(err)));
// 初期化エラーがあっても基本機能は使えるようにする
setIsReady(true);
}
}
initialize();
}, [userId]);
return { isReady, error };
}
よくある間違いと落とし穴
実際に実装してみて詰まりやすいポイントを3つ紹介します。
落とし穴 1: RevenueCat と PostHog のユーザーIDが一致しない
これが最も多いミスです。RevenueCat の logIn に渡す userId と、PostHog の identify に渡す userId が異なると、課金ユーザーと行動データが紐付けられません。
// ❌ よくある間違い: 別々の ID を使ってしまう
await Purchases.logIn(auth.uid); // Firebase の UID
posthog.identify(deviceId); // デバイスID
// ✅ 正しい実装: 同じ userId を使う
const userId = auth.uid; // 認証基盤の ID に統一
await Purchases.logIn(userId);
posthog.identify(userId);
落とし穴 2: Superwall の register をコンポーネントのレンダリングごとに呼んでしまう
showContextualPaywall を useEffect 内でなく JSX 内に書いてしまうと、レンダリングのたびにペイウォールが表示されます。
// ❌ 間違い: レンダリングのたびに呼ばれる
function FeatureScreen() {
showContextualPaywall('feature_accessed', {...}); // ここに書いてはいけない
return <View>...</View>;
}
// ✅ 正しい実装: useEffect で一度だけ呼ぶ
function FeatureScreen() {
useEffect(() => {
showContextualPaywall('feature_accessed', {...});
}, []); // 空の依存配列で初回レンダリング時のみ実行
return <View>...</View>;
}
落とし穴 3: チャーン通知を送りすぎる
介入通知の間隔を制御しないと、短期間に複数の通知が届いてアンインストールを誘発します。上記のコードでは 7 日間の再送防止を実装していますが、通知の内容も重要です。「久しぶりですね、戻ってきてください」という汎用メッセージより、「先週使っていた○○機能の続きはいかがですか?」というパーソナライズされた文言の方が開封率が2〜3倍になります。
通知文言のパーソナライズには、バックエンドの Edge Function で最後に使った機能名を取り出してテンプレートに埋め込む実装が必要です。詳細はAI チャーン予測とプッシュ通知実装ガイドで解説しています。
PostHog ダッシュボードで見るべき3つの指標
実装が完了したら、PostHog で以下のダッシュボードを作成することをおすすめします。
指標 1: ペイウォール → 課金のコンバージョンファネル
paywall_viewed → purchase_initiated → purchase_completed の3ステップのファネルを作成します。どのステップで離脱が多いかが見えるだけで、改善の優先順位が変わります。一般的に、purchase_initiated から purchase_completed の離脱率が高い場合は価格設定の問題、paywall_viewed から purchase_initiated の離脱率が高い場合はペイウォールのデザインや文言の問題です。
指標 2: チャーンリスクスコアの分布
毎日の churn_risk_evaluated イベントのリスクスコアをヒストグラムで表示します。高リスクユーザーの割合が全体の 20% を超えたら、何か根本的な問題がある(機能が使いにくい、価格が高すぎる等)サインと考えて良いでしょう。
指標 3: コホート別 LTV
インストール日でコホートを切り、各コホートの収益推移を比較します。最新のコホートの LTV が過去のコホートより低下している場合、プロダクトの品質低下か市場の変化が起きている可能性があります。
本番運用に向けての補足
この実装を本番環境に持っていく前に確認しておきたいことがあります。
プライバシーポリシーの更新: PostHog でユーザー行動を収集する場合、プライバシーポリシーに「行動データの収集と利用目的」を明記する必要があります。App Store の審査でも確認されるポイントです。
テスト環境の分離: RevenueCat の Sandbox 環境と PostHog のテスト環境を本番と分離して設定しましょう。__DEV__ フラグを使って環境変数を切り替えるのが最もシンプルです。
パフォーマンスへの影響: 起動時に複数のサービスを初期化するため、Cold Start が遅くなる場合があります。Promise.allSettled を使って並列初期化し、初期化エラーが起動を妨げないように設計する点が肝心です。
個人開発者の視点から(実体験メモ)
全体を振り返ってにかえて
RevenueCat + PostHog + Superwall の連携は、最初のセットアップに半日〜1日程度かかります。ですが一度動き始めると、「なんとなく」ではなく「データを見ながら」改善を繰り返せるようになります。
最初の一歩として、まず PostHog だけ入れてみることをおすすめします。ペイウォールを何回表示して何回課金が起きているか、それだけでも見えると、次に何を改善すべきかが自然にわかってきます。データが集まりはじめたら、Superwall の A/B テストと組み合わせて、ペイウォールの最適化に進んでください。
個人開発の強みは、大企業では難しい「小さな仮説を素早く検証する」ことができる点にあります。このデータスタックを使いこなせれば、その強みが最大限に活きるはずです。