「アプリを App Store にリリースした後、ダウンロード数は伸びても収益がなかなか積み上がらない」— Rork Max でアプリ開発を始めたばかりの方から、最も多く聞く悩みです。
私自身、Rork Max でリリースした自作アプリで同じ壁にぶつかりました。月額サブスクリプションを設定したものの、初月の試用が終わると半分以上のユーザーが解約していく。半年悩み続けた結果、「初回獲得の最適化」よりも「解約防止と再獲得」のほうが、はるかに ROI の高い領域だと気づきました。
ここではRork Max でリリースしたアプリの継続収益を最大化するため、オファーコード配布・ウィンバックオファー・プッシュ通知連動を、StoreKit 2 と Google Play Billing 両対応で実装する完全な設計を共有します。
なぜ「再獲得」が新規獲得より重要か
数字で示します。新規ユーザー1人を獲得するのに ASA / Google Ads で平均 $5〜$15 かかります。一方、過去に解約したユーザーを再獲得するコストは、適切な設計なら $0.50〜$2 程度に収まります。約10倍の効率差です。
さらに、過去にお金を払ったことがあるユーザーは「製品の価値を一度は認めた」状態にあります。解約理由が「価格」「タイミング」「機能不足」のいずれかであれば、それぞれに対応した再獲得施策で戻ってくる確率が高まります。
私の運用データでは、解約から30日以内のウィンバックオファーで戻ってきたユーザーの LTV は、新規獲得ユーザーの平均 LTV より20〜40% 高い傾向があります。理由は単純で、「製品を理解した上で再選択している」からです。
全体アーキテクチャ — 4層構造
実装に入る前に、システム全体を4層に分けて整理します。
第1層は データ収集層 。ユーザーの利用状況・サブスクリプション状態・解約理由を蓄積します。Cloudflare D1 や Firestore などのバックエンドが選択肢です。
第2層は ロジック判定層 。「いつ・誰に・どんなオファーを出すか」を決定します。ルールベースのシンプルなものから、機械学習モデルを使った高度なものまで、段階的に発展させられます。
第3層は 配信実行層 。プッシュ通知、メール、アプリ内ポップアップ、App Store のオファーコード配布を実行します。
第4層は 計測・分析層 。各施策の効果を測定し、次の施策にフィードバックします。
ステップ1: StoreKit 2 のオファーコード生成
まず iOS 側のオファーコード配布実装です。StoreKit 2 では、サーバー側で署名済みのオファーコードを生成し、アプリ経由で適用する設計になっています。
// iOS 側: オファーコード適用
import StoreKit
func applyPromotionalOffer ( productId : String , offerSignature : SignedOffer) async throws {
guard let product = try await Product. products ( for : [productId]). first else {
throw OfferError.productNotFound
}
let result = try await product. purchase ( options : [
. promotionalOffer (
offerID : offerSignature.offerId,
keyID : offerSignature.keyId,
nonce : offerSignature.nonce,
signature : offerSignature.signature,
timestamp : offerSignature.timestamp
)
])
switch result {
case . success ( let verification) :
switch verification {
case . verified ( let transaction) :
await transaction. finish ()
case . unverified ( _ , let error) :
throw error
}
case .userCancelled :
break
case .pending :
break
@unknown default:
break
}
}
struct SignedOffer {
let offerId: String
let keyId: String
let nonce: UUID
let signature: Data
let timestamp: Int64
}
サーバー側で署名するコードは以下の通りです。Apple が発行する PKCS#8 形式の秘密鍵を使って ECDSA 署名を生成します。
// サーバー側: オファー署名生成(Node.js)
import { createSign } from "node:crypto" ;
interface OfferParams {
appBundleId : string ;
productId : string ;
offerId : string ;
applicationUsername : string ; // ユーザーの一意 ID
privateKey : string ; // PEM 形式の秘密鍵
keyId : string ; // App Store Connect で発行される Key ID
}
export function signPromotionalOffer ( params : OfferParams ) {
const nonce = crypto. randomUUID ();
const timestamp = Math. floor (Date. now () / 1000 );
// 署名対象文字列の構築(順序が重要)
const payload = [
params.appBundleId,
params.keyId,
params.productId,
params.offerId,
params.applicationUsername,
nonce,
timestamp. toString (),
]. join ( "" ); // U+2063 区切り
const sign = createSign ( "SHA256" );
sign. update (payload);
sign. end ();
const signature = sign. sign ({
key: params.privateKey,
dsaEncoding: "der" ,
});
return {
offerId: params.offerId,
keyId: params.keyId,
nonce,
signature: signature. toString ( "base64" ),
timestamp,
};
}
このコードで重要なのは、ペイロードの区切り文字に (INVISIBLE SEPARATOR)を使う点です。Apple のドキュメントに記載されている仕様で、これ以外の文字を使うと署名が無効になります。
ステップ2: Google Play Billing のオファー実装
Android 側は Google Play Billing で同様の仕組みを実装します。Google の場合は、オファーIDをサーバー側で生成し、アプリ側で BillingFlowParams に渡す形です。
// Android 側: オファー適用
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.QueryProductDetailsParams
suspend fun launchOfferFlow (
activity: Activity ,
productId: String ,
offerToken: String
) {
val productList = listOf (
QueryProductDetailsParams.Product. newBuilder ()
. setProductId (productId)
. setProductType (BillingClient.ProductType.SUBS)
. build ()
)
val params = QueryProductDetailsParams. newBuilder ()
. setProductList (productList)
. build ()
val productDetailsResult = billingClient. queryProductDetails (params)
val productDetails = productDetailsResult.productDetailsList?. firstOrNull () ?: return
val productDetailsParamsList = listOf (
BillingFlowParams.ProductDetailsParams. newBuilder ()
. setProductDetails (productDetails)
. setOfferToken (offerToken) // サーバーから取得したオファートークン
. build ()
)
val billingFlowParams = BillingFlowParams. newBuilder ()
. setProductDetailsParamsList (productDetailsParamsList)
. build ()
billingClient. launchBillingFlow (activity, billingFlowParams)
}
サーバー側でオファートークンを生成する場合、Google Play Developer API 経由で事前にオファーを作成しておき、その offerToken をユーザー単位で配布します。
ステップ3: ウィンバックオファーの判定ロジック
「誰に・いつ・どのオファーを出すか」のロジックを実装します。ルールベースから始めて、データが溜まってから機械学習に移行するのが現実的です。
// src/lib/winback-offer-engine.ts
interface UserProfile {
userId : string ;
cancelDate : Date ;
cancelReason ?: "price" | "not_using" | "found_alternative" | "other" ;
totalLifetimeRevenue : number ;
monthsActive : number ;
lastSessionDays : number ;
}
interface OfferRecommendation {
productId : string ;
offerId : string ;
channel : "push" | "email" | "in_app" ;
message : string ;
}
export function recommendWinbackOffer ( profile : UserProfile ) : OfferRecommendation | null {
const daysSinceCancel = Math. floor (
(Date. now () - profile.cancelDate. getTime ()) / ( 1000 * 60 * 60 * 24 )
);
// タイミング: 解約から 7日 / 30日 / 90日でアプローチ
if ( ! [ 7 , 30 , 90 ]. includes (daysSinceCancel)) return null ;
// 解約理由別のオファー
switch (profile.cancelReason) {
case "price" :
return {
productId: "com.example.app.subscription.monthly" ,
offerId: "winback_50_off_3months" ,
channel: "push" ,
message: "戻ってきてくれませんか?3か月50%オフでお試しいただけます。" ,
};
case "not_using" :
return {
productId: "com.example.app.subscription.monthly" ,
offerId: "winback_first_month_free" ,
channel: "email" ,
message: "最近のアップデートで使いやすくなりました。1か月無料でお試しください。" ,
};
case "found_alternative" :
// 競合に流れたユーザーには年間プラン提示で長期囲い込み
return {
productId: "com.example.app.subscription.yearly" ,
offerId: "winback_yearly_30_off" ,
channel: "in_app" ,
message: "年間プランが30%オフでご利用いただけます。" ,
};
default :
// LTV が高かったユーザーには手厚いオファー
if (profile.totalLifetimeRevenue > 50 ) {
return {
productId: "com.example.app.subscription.yearly" ,
offerId: "winback_loyal_50_off" ,
channel: "push" ,
message: "また一緒に過ごしたいです。年間プラン50%オフでお戻りください。" ,
};
}
return null ;
}
}
このロジックの肝は、「解約理由別に異なるオファーを出す」点です。価格で離れたユーザーには値引きが、機能不足で離れたユーザーには新機能訴求と無料期間が、それぞれ刺さります。
ステップ4: プッシュ通知との連動
プッシュ通知でオファーを届ける実装です。FCM(Firebase Cloud Messaging)を使った例を示します。
// src/lib/push-notification-sender.ts
import { getMessaging } from "firebase-admin/messaging" ;
interface OfferNotification {
userId : string ;
fcmToken : string ;
offerId : string ;
productId : string ;
message : string ;
deepLink : string ; // アプリ内のオファー画面へのディープリンク
}
export async function sendOfferPushNotification ( args : OfferNotification ) {
const messaging = getMessaging ();
await messaging. send ({
token: args.fcmToken,
notification: {
title: "特別なお知らせがあります" ,
body: args.message,
},
data: {
type: "winback_offer" ,
offerId: args.offerId,
productId: args.productId,
deepLink: args.deepLink,
},
apns: {
payload: {
aps: {
sound: "default" ,
badge: 1 ,
"mutable-content" : 1 ,
},
},
},
android: {
priority: "high" ,
notification: {
clickAction: "OPEN_OFFER" ,
},
},
});
// 配信ログを記録(A/Bテスト・効果分析のため)
await logOfferDelivery ({
userId: args.userId,
offerId: args.offerId,
sentAt: new Date (),
});
}
deepLink の設計が重要です。プッシュ通知をタップしたら、即座にアプリ内のオファー画面が開き、ボタン1つで購入確認に進める導線を作ります。「アプリを開いてからオファーを探す」というステップを残すと、ほとんどのユーザーは離脱します。
ステップ5: 解約理由の収集
ウィンバックの精度は、解約理由の収集精度に依存します。App Store / Google Play 自体は解約理由を提供してくれないため、自前で収集する仕組みを入れます。
// iOS 側: 解約画面の前に理由を聞く
import SwiftUI
import StoreKit
struct CancellationFlowView : View {
@State private var selectedReason: CancelReason ?
@State private var feedback: String = ""
var body: some View {
VStack ( spacing : 16 ) {
Text ( "解約の前に、お聞かせください" )
. font (.title2)
Text ( "私たちのアプリのどこを改善できるか、教えていただけませんか?" )
. multilineTextAlignment (.center)
. padding ()
ForEach (CancelReason.allCases) { reason in
Button {
selectedReason = reason
} label : {
HStack {
Image ( systemName : selectedReason == reason ? "circle.fill" : "circle" )
Text (reason.label)
Spacer ()
}
. padding ()
}
}
TextEditor ( text : $feedback)
. frame ( height : 100 )
. border (Color.gray. opacity ( 0.3 ))
Button ( "送信して解約画面へ" ) {
Task {
await submitCancellationReason ( reason : selectedReason, feedback : feedback)
// 送信後、Apple の解約画面へ遷移
if let url = URL ( string : "https://apps.apple.com/account/subscriptions" ) {
await UIApplication.shared. open (url)
}
}
}
}
. padding ()
}
}
enum CancelReason : String , CaseIterable , Identifiable {
case tooExpensive = "price"
case notUsing = "not_using"
case foundAlternative = "found_alternative"
case missingFeature = "missing_feature"
case other = "other"
var id: String { rawValue }
var label: String {
switch self {
case .tooExpensive : return "料金が高い"
case .notUsing : return "あまり使わなくなった"
case .foundAlternative : return "他のサービスを使い始めた"
case .missingFeature : return "欲しい機能がない"
case .other : return "その他"
}
}
}
このフローの設計で重要なのは、「強制ではなく任意」にすることと、「解約画面へのアクセスを妨げない」ことです。理由聞き取りを必須にすると Apple のガイドライン違反になります。あくまで「教えてくれたら嬉しい」というスタンスで実装します。
ステップ6: A/Bテストと効果計測
実装したオファーの効果を測定します。同じセグメントのユーザーをランダムに2グループに分け、片方にオファーを出し、もう片方には出さない比較を行います。
// src/lib/ab-test.ts
export function shouldShowOffer ( userId : string , experimentId : string ) : boolean {
// userId と experimentId のハッシュで安定的に振り分け
const hash = simpleHash ( `${ userId }:${ experimentId }` );
return hash % 100 < 50 ; // 50% の確率で表示
}
interface ExperimentMetrics {
experimentId : string ;
variant : "control" | "treatment" ;
exposureCount : number ;
conversionCount : number ;
revenueGenerated : number ;
}
export async function trackExposure ( args : {
userId : string ;
experimentId : string ;
variant : "control" | "treatment" ;
}) {
await db.exposures. insert ({
userId: args.userId,
experimentId: args.experimentId,
variant: args.variant,
exposedAt: new Date (),
});
}
export async function trackConversion ( args : {
userId : string ;
experimentId : string ;
revenueAmount : number ;
}) {
// 直近 30日以内に exposure があった場合のみ conversion としてカウント
const exposure = await db.exposures. findOne ({
userId: args.userId,
experimentId: args.experimentId,
exposedAt: { $gte: new Date (Date. now () - 30 * 24 * 60 * 60 * 1000 ) },
});
if ( ! exposure) return ;
await db.conversions. insert ({
userId: args.userId,
experimentId: args.experimentId,
variant: exposure.variant,
revenueAmount: args.revenueAmount,
convertedAt: new Date (),
});
}
統計的有意性を判定するには、最低でも各バリアントで500件以上のエクスポージャーが必要です。少サンプルでの判断は誤りの元になります。
メール配信チャネルを併設する
プッシュ通知が定番の配信路ですが、アプリのウィンバックではメールが驚くほど使われていません。プッシュと並行してメール配信を用意しておくと、通知をオフにしてしまった休眠ユーザーにも届けられます。
基本は、初回のサインアップ時に明示的な同意を取ってメールアドレスを収集し、プッシュトークンと同じユーザー ID に紐づけて保存しておくことです。ウィンバックエンジンがオファーを推奨したとき、ユーザーの状態に応じて配信路を選びます。直近まで使っていた方にはプッシュ、完全に離れてしまった方にはメール、という具合です。
// src/lib/email-winback-sender.ts
import { Resend } from "resend" ;
interface EmailWinbackArgs {
userEmail : string ;
userName ?: string ;
offerId : string ;
productId : string ;
discountPercent : number ;
appStoreUrl : string ;
unsubscribeUrl : string ;
}
export async function sendEmailWinback ( args : EmailWinbackArgs ) {
const resend = new Resend (process.env. RESEND_API_KEY ! );
const subject = `${ args . userName ? args . userName + "さん、" : ""}特別なご提案があります` ;
await resend.emails. send ({
from: "Rork Max App <hello@example.com>" ,
to: args.userEmail,
subject,
html: buildEmailHtml (args),
text: buildEmailText (args),
headers: {
"List-Unsubscribe" : `<${ args . unsubscribeUrl }>` ,
"List-Unsubscribe-Post" : "List-Unsubscribe=One-Click" ,
},
});
await logEmailDelivery ({
userEmail: args.userEmail,
offerId: args.offerId,
sentAt: new Date (),
});
}
ここで地味に効くのが List-Unsubscribe ヘッダーです。Gmail も Apple Mail も、このヘッダーがあると配信解除リンクを目立つ位置に表示してくれます。解除導線がはっきり見えることで、迷惑メール報告がぐっと減ります。迷惑メール報告は送信者レピュテーションを傷つけ、それが今後すべての配信に響きますので、このヘッダーは必ず入れておきたいところです。
サブスクのライフサイクルで見落としがちな境界ケース
実装が一通り動き始めてから慌てやすいのが、サブスクリプションの状態遷移にひそむ境界ケースです。代表的なものを挙げておきます。
グレースピリオド(支払い猶予期間) 。クレジットカードの期限切れや決済失敗が起きたとき、Apple も Google も通常16日ほどの猶予期間を設けます。この間ユーザーは利用を続けられますが、裏では課金のリトライが走っています。アプリ側では「グレースピリオド中」を独立した状態として扱い、権限としてはアクティブ扱いにしつつ、支払い方法の更新を促すバナーを出すのが定石です。
返金 。Apple の返金処理は非同期です。今日返金した方が、数時間から数日のあいだ記録上はまだ権限を持っているように見えることがあります。レシートの再検証に頼らず、App Store Server Notifications を購読して、返金イベントを受け取った瞬間に権限を取り消す設計にしておきます。
ファミリー共有 。Apple のファミリー共有では、1つのサブスクで最大6人まで権限が及びます。RevenueCat を使えば自動で処理されますが、自前で組む場合は App Store Server API でファミリー共有の関係を検証する必要があります。
再課金のタイミング 。期間の途中で解約してすぐ再課金した方に対し、両プラットフォームとも二重請求はしませんが、サーバー側では「これは新規ではなく再契約だ」と認識し、オンボーディングメールや試用特典を重複して出さないようにします。直前のサブスク状態を記録し、新規フローを起動する前に確認します。
RevenueCat か自前レシート検証か
Rork Max で作ったアプリを運用していると、誰もが一度は迷う選択があります。RevenueCat のようなサービスを使うか、レシート検証を自前で組むか、という問いです。
RevenueCat の利点は、StoreKit 2 と Google Play Billing の差異を吸収してくれること、グレースピリオドやファミリー共有といった境界ケースを自動で扱ってくれること、そしてクロスプラットフォームの指標をひとつのダッシュボードで見られることです。多くの個人開発者にとって、節約できる時間は売上連動の手数料を大きく上回ります。
一方で、規模が大きくなると売上に対する手数料が無視できなくなります。月10,000ドルの継続収益に対する1%は月100ドルで、これは売上に比例して増えていきます。自前のレシート検証は一度組んでしまえば、その後はいくら伸びても追加費用なしで回せます。
私のおすすめは、最初の月次継続収益が5,000ドルに届くまでは RevenueCat で進め、そこで一度見直す、という順序です。立ち上げ期に節約できる時間は、手数料よりはるかに価値があります。手数料が気になる規模になる頃には、自前化の複雑さに見合う節約かどうかを判断するためのデータが手元に揃っています。
移行する場合は慎重に計画してください。レシート検証の移行は課金中の全顧客に触れる作業で、小さなバグでもサブスク状態の破損につながり、復旧には大きなコストがかかります。最低でも3週間は確保し、切り替え前に十分な並行稼働期間を取ることをおすすめします。
ステップ7: 実運用での落とし穴
実装中に詰まりやすいポイントです。
間違い1: オファー署名の有効期限切れ
StoreKit 2 のオファー署名には有効期限があります(通常24時間)。サーバー側で生成して長時間保持し、ユーザーがアプリを開いた時に期限切れになっているケースがあります。クライアント側で署名取得→即座に決済画面表示、の流れを徹底します。
間違い2: 過剰なプッシュ通知
ウィンバックオファーのプッシュ通知を頻繁に送ると、ユーザーがアプリ通知を OFF にする原因になります。1ユーザーあたり月3回までを目安に、頻度制限を実装します。
間違い3: 解約済みユーザーへの通知配信
FCM トークンが残っているからといって、完全にアプリを削除したユーザーに通知を送り続けると、通知の非配信率が上昇し、Firebase 側のレピュテーションが下がります。30日間アクティブでないユーザーは配信対象から外す仕組みを入れます。
間違い4: ディープリンクの不具合
オファー画面へのディープリンクが正しく動作しないと、ユーザーがアプリを開いただけで終わってしまいます。リリース前に必ず実機テストで、プッシュ通知タップ → オファー画面表示 → 購入確認、までの一連のフローを確認します。
間違い5: A/Bテストの早期判断
「3日間で treatment が control より売上が高い」という結果が出ても、それが統計的に有意とは限りません。最低2週間、できれば1か月のデータで判断します。
実運用の数字 — 私のアプリでの効果
私の運用しているアプリで、本実装を3か月運用した結果です。
ウィンバックオファー実装前の月次解約率 : 12%
実装後の月次解約率 : 7%(5pt 改善)
解約から30日以内のウィンバック率 : 18%
再課金ユーザーの平均 LTV : 通常獲得ユーザーの125%
施策実装にかかった時間 : 約40時間(既存アプリへの追加実装)
特に効果が大きかったのは、価格が解約理由のユーザーへの「3か月50%オフ」オファーです。15%程度のユーザーが戻ってきてくれました。
1年運用して見えた5つの教訓
フルのウィンバック基盤を1年回してみて、見えてきた手触りの教訓があります。
ひとつめ。解約理由の質は、手を入れ続けないと時間とともに劣化します 。ユーザーは理由の選択肢を見ても、深く考えずに素早くタップして通り過ぎていきます。半年に一度は文言を見直し、「その他」の回答を定期的に分類し直します。新しく追加すべき理由のカテゴリが、そこから見えてきます。
ふたつめ。最も効くのは値引きそのものではなく、パーソナライズです 。「この機能を47日間使ってくださっていましたね。また一緒に続けられたら嬉しいです」というメッセージは、汎用的な「戻ってきてください」より、私の計測でおよそ2倍の反応がありました。ひとりの個人として扱われていると感じた方は、はるかに高い率で応えてくれます。
みっつめ。ウィンバックの複利効果を侮らないことです 。一度ウィンバックで戻り、半年使い、また離れ、再び戻る——こうしたユーザーは、最終的に「獲得して一度も離れなかった」典型的なユーザーよりも多くの収益をもたらします。ウィンバックは一回限りのコンバージョンではなく、長期のループとして扱います。
よっつめ。再獲得した方の継続曲線をよく見ます 。再獲得ユーザーが新規よりも速く離れていくなら、そのオファーは強すぎたということです。本当は興味のなかった方をお金で買い戻してしまっています。再獲得ユーザーの継続率が新規の80%以上に収まることを目安にし、それを下回るならオファーの設計を見直します。
いつつめ。高単価のウィンバックではメールがプッシュを上回ります 。プッシュは低 LTV の再獲得に効きますが、高 LTV で離れた方はたいてい通知をオフにしています。パーソナライズを盛り込んだメールなら届きますが、プッシュは届きません。
この5つは、規模を保ったまま1年間回してはじめて見えてきたものです。まずは基本形を作ってしまうこと。教訓は、データが溜まってから自然と姿を現します。
全体を振り返って — まず1つの施策から始める
ここまでの実装を一気にやろうとすると挫折します。最初の1か月は「解約理由の収集」だけ、次の1か月で「最も多い解約理由に対応するオファー1つ」だけ、というペースで進めます。
私自身、最初は「料金で解約したユーザーへのプッシュ通知 + 50%オフコード配布」だけから始めました。これだけで月次解約率が3pt 改善し、追加施策を打つ余裕と意欲が生まれました。
完璧な設計を目指して何も実装しないより、不完全でも1つの施策を回し始めるほうが、結果的に収益は早く伸びます。今日できる1歩は、自分のアプリの解約率を計測することから。数字さえ手元にあれば、次の打ち手は自然と見えてきます。