サブスクリプションアプリが単一のプラットフォームだけで完結することはほとんどありません。ユーザーは朝の通勤中にiPhoneでサブスクに登録し、帰宅後にAndroidタブレットでアプリを開いて、当然のようにフルアクセスできることを期待します。この期待はユーザーの立場からすれば直感的です。サブスクリプションに支払ったのだから、どこでも使えるはずだ、というわけです。しかし、開発者の視点では、これを実現することはサブスクリプション基盤の中でも最も難しい問題のひとつです。Google Play BillingとAppleのStoreKitは完全に別個のシステムであり、レシート形式も、検証メカニズムも、通知システムも、そして購入情報の表現方法に関する前提も根本的に異なります。両者の間に組み込みの相互運用性は存在しません。

本記事では、なぜクロスプラットフォームのサブスクリプション状態の実装がこれほど難しいのかを掘り下げ、Google Play BillingとStoreKitの根本的な非互換性を検証し、ゼロからクロスプラットフォームのエンタイトルメント同期を構築するには何が必要かを見ていきます。そして、特に小規模チームやインディー開発者にとって、必要なエンジニアリング工数を大幅に削減できる自然な解決策として、RevenueCatのアイデンティティシステムがどのように機能するかを紹介します。

根本的な問題:1人のユーザー、2つのエコシステム

「Premium」サブスクリプションを提供するフィットネスアプリを考えてみましょう。あるユーザーがiPhoneのApp Store経由でサブスクに登録します。1週間後、そのユーザーはAndroidタブレットを購入し、あなたのアプリをダウンロードします。同じアカウントでログインし、当然のようにプレミアム機能が利用できることを期待します。では、実際には何が起きるのでしょうか?

クロスプラットフォームの基盤がなければ、Androidアプリはそのユーザーが有効なサブスクリプションを持っていることを認識できません。Google Play Billingは、Google Play経由で行われた購入しか把握していません。Appleのサーバー上にあるApp Storeのレシートは、Androidアプリからは見えないのです。その結果、ユーザーはすでに支払いをしているにもかかわらず、再度サブスク登録を求めるペイウォールを目にすることになります。

1// On the Android side, this returns nothing
2val params = QueryPurchasesParams.newBuilder()
3    .setProductType(BillingClient.ProductType.SUBS)
4    .build()
5
6billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
7    // purchases is empty because the user subscribed through Apple
8    // The Android app has no way to know about the iOS subscription
9    if (purchases.isEmpty()) {
10        showPaywall() // User sees this despite having an active subscription
11    }
12}

これはバグではありません。想定どおりの挙動です。各課金システムは独立して動作しており、それらを橋渡しするには、どちらのプラットフォームも提供していない大規模なインフラが必要になります。

2つの課金システム、相互運用性はゼロ

なぜクロスプラットフォーム同期がこれほど難しいのかを理解するには、GoogleとAppleが購入情報をどれほど異なる方法で表現しているかを理解する必要があります。これは単なるAPIの細かな違いではありません。根本的に異なるアーキテクチャなのです。

レシート形式と検証

AppleとGoogleは、購入が実際に行われたことを証明するために、まったく異なる仕組みを採用しています。

項目Google Play BillingApple StoreKit
購入証明購入トークン(不透明な文字列)署名付きレシート(StoreKit 2のJWS)
検証エンドポイントpurchases.subscriptionsv2.get REST APIApp Store Server API (/inApps/v1/subscriptions)
認証JSONキーを用いたGoogleサービスアカウントApp Store Connectの秘密鍵で署名したJWT
レスポンス形式SubscriptionPurchaseV2 JSONオブジェクトJWSTransactionDecodedPayload (署名付きJSON)
サブスクリプションID形式productId:basePlanIdシンプルな productId 文字列
更新(リニューアル)管理サブスクリプションリソースのexpiryTime フィールドトランザクション情報内の expiresDate

Google Playは購入トークンモデルを採用しています。ユーザーがサブスクに登録すると、アプリは購入トークンを受け取ります。このトークンをGoogle Play Developer APIに送信すると、現在のサブスクリプション状態が返されます。このトークン自体は意味を持たない不透明な文字列です。

一方、Appleは署名付きトランザクションモデルを採用しています。StoreKit 2では、購入情報はJSON Web Signature(JWS)として提供され、サーバー側でAppleの公開鍵を用いて検証できます。各トランザクションは、暗号学的に署名された自己完結型の記録です。

これは、同じ概念を異なるAPIでラップしているだけではありません。信頼の所在に対する哲学そのものが異なります。Googleは「我々のサーバーに問い合わせれば状態を教える」と言い、Appleは「暗号署名された証明を渡すので、自分で検証せよ」と言っているのです。

リアルタイム通知

両プラットフォームとも、サブスクリプションイベントに関するサーバー間通知(server-to-server notifications)を提供しています。しかし、その通知システムは大きく異なります。

項目Google Play RTDNApple Server Notifications V2
配信方式Google Cloud Pub/SubあなたのエンドポイントへのHTTPS POST
通知フォーマットtype enumを含む DeveloperNotificationnotificationType を含む signedPayload (JWS) 
イベント種別SUBSCRIPTION_RENEWEDSUBSCRIPTION_CANCELED などDID_RENEWDID_CHANGE_RENEWAL_STATUS など
ユーザー識別子通知内の purchaseToken署名ペイロード内の originalTransactionId
セットアップPlay ConsoleでPub/Subトピックを設定App Store ConnectでURLを登録

GoogleはCloud Pub/Subを通じて通知を配信するため、Pub/Subサブスクリプションと、それを処理するサービスをセットアップする必要があります。一方Appleは、設定したURLに対してHTTPS POSTリクエストを直接送信します。イベント名も異なり、ペイロード構造も異なり、各通知タイプに含まれる情報も異なります。

つまり、バックエンド側では、認証方法も、パースロジックも、状態遷移(ステートマシン)の解釈も異なる、2つの完全に別々の通知処理パイプラインが必要になるのです。

プロダクト設定

サブスクリプションプロダクトの定義方法も、プラットフォーム間で異なります。

Google Playは2022年にベースプランとオファーを導入し、階層型のプロダクトモデルを採用しました。1つのサブスクリプションには1つ以上のベースプランが含まれ、それぞれに異なる価格フェーズを持つ複数のオファーを設定できます。単一のサブスクリプションプロダクトIDに、月額・年額のベースプラン、導入オファー、プロモーション価格などをすべて含めることができ、これらはPlay Console上で設定されます。

一方、Appleのプロダクトモデルはよりフラットです。App Store Connectにおける各プロダクトIDは、単一の期間を持つ1つのサブスクリプションを表します。月額と年額の両方を提供する場合は、2つの別々のプロダクトIDを作成し、それらをサブスクリプショングループにまとめます。導入オファーやプロモーションオファーは、ネストされた構造ではなく、各プロダクト単位で設定されます。

この構造の違いにより、Google PlayのサブスクリプションプロダクトとAppleのサブスクリプションプロダクトの間には、1対1の対応関係は存在しません。そのため、バックエンド側では、プラットフォーム固有のプロダクト識別子を統一されたエンタイトルメント概念へ変換するマッピングレイヤーを維持する必要があります。

自前でクロスプラットフォーム同期を構築する

サードパーティのサービスを使わずにクロスプラットフォームのサブスクリプション同期を構築する場合、アーキテクチャは次のようになります。この作業量を理解しておくことは、最終的にマネージドなソリューションを選ぶ場合でも重要です。というのも、なぜこの問題が本質的に難しいのかが見えてくるからです。

ステップ1:統一されたユーザーID(ユーザー同一性)

最初に必要なのは、プラットフォームをまたいで機能するユーザーID(ユーザー同一性)システムです。各プラットフォームにはそれぞれ独自の「ユーザー」の概念がありますが、どちらももう片方の存在を認識しません。そこで、iOSとAndroidの両方が購入情報を紐づけられるサーバー側のユーザーアカウントが必要になります。

1// Android client: associate purchase with your user account
2fun postPurchaseToBackend(purchase: Purchase, userId: String) {
3    val request = PurchaseVerificationRequest(
4        platform = "android",
5        purchaseToken = purchase.purchaseToken,
6        productId = purchase.products.first(),
7        userId = userId,
8    )
9
10    backendApi.verifyAndRecordPurchase(request)
11}

iOS側についても、Android側と大きく変わりません:

1// iOS client: associate purchase with your user account
2func postPurchaseToBackend(transaction: Transaction, userId: String) async {
3    let request = PurchaseVerificationRequest(
4        platform: "ios",
5        transactionId: String(transaction.originalID),
6        productId: transaction.productID,
7        userId: userId
8    )
9
10    await backendAPI.verifyAndRecordPurchase(request)
11}

両方のクライアント(iOS/Android)は、同じ userId を付けて購入データをバックエンドに送信します。バックエンドは、各購入を適切なプラットフォーム(Apple/Google)のAPIで検証し、その結果得られたエンタイトルメント(権利)を、統一されたユーザーアカウントに紐付けて記録する必要があります。

ステップ2:2系統のレシート検証

バックエンドは、両プラットフォーム(iOS/Android)からの購入を検証できる必要があります。そのためには、まったく異なる2つの検証API(Apple用とGoogle用)に統合することになります。

1// Backend: platform-specific verification
2class PurchaseVerifier(
3    private val playDeveloperApi: AndroidPublisher,
4    private val appStoreServerApi: AppStoreServerAPIClient,
5) {
6    suspend fun verify(request: PurchaseVerificationRequest): VerificationResult {
7        return when (request.platform) {
8            "android" -> verifyGooglePurchase(request)
9            "ios" -> verifyApplePurchase(request)
10            else -> VerificationResult.InvalidPlatform
11        }
12    }
13
14    private suspend fun verifyGooglePurchase(
15        request: PurchaseVerificationRequest,
16    ): VerificationResult {
17        val subscription = playDeveloperApi
18            .purchases()
19            .subscriptionsv2()
20            .get(packageName, request.purchaseToken)
21            .execute()
22
23        return if (subscription.subscriptionState == "SUBSCRIPTION_STATE_ACTIVE") {
24            VerificationResult.Valid(
25                expiryTime = subscription.lineItems[0].expiryTime,
26                productId = subscription.lineItems[0].productId,
27                platform = "android",
28            )
29        } else {
30            VerificationResult.Expired
31        }
32    }
33
34    private suspend fun verifyApplePurchase(
35        request: PurchaseVerificationRequest,
36    ): VerificationResult {
37        // Uses Apple's App Store Server API
38        val statusResponse = appStoreServerApi
39            .getAllSubscriptionStatuses(request.transactionId)
40
41        val activeSubscription = statusResponse.data
42            .flatMap { it.lastTransactions }
43            .find { it.status == Status.ACTIVE }
44
45        return if (activeSubscription != null) {
46            val transactionInfo = activeSubscription.signedTransactionInfo
47            VerificationResult.Valid(
48                expiryTime = transactionInfo.expiresDate,
49                productId = transactionInfo.productId,
50                platform = "ios",
51            )
52        } else {
53            VerificationResult.Expired
54        }
55    }
56}

各検証フローには、それぞれ認証設定、エラーハンドリング、レスポンス解析が必要です。Google 側ではサービスアカウントの認証情報が必要になります。Apple 側では App Store Connect の秘密鍵で署名した JWT が必要です。さらに、レスポンス形式には共通の構造が一切ありません。

ステップ3:統一されたエンタイトルメントストレージ

バックエンドには、プラットフォーム固有のプロダクトをプラットフォーム非依存のエンタイトルメントにマッピングするデータモデルが必要です。

1// Backend entitlement model
2data class UserEntitlement(
3    val userId: String,
4    val entitlementId: String,         // e.g., "premium"
5    val isActive: Boolean,
6    val sourcePlatform: String,        // "android" or "ios"
7    val platformProductId: String,     // Platform-specific product ID
8    val platformPurchaseToken: String, // Platform-specific purchase proof
9    val expiresAt: Instant?,
10    val lastVerifiedAt: Instant,
11)
12
13// Product mapping configuration
14val productToEntitlementMap = mapOf(
15    // Google Play products
16    "premium_monthly:monthly-base" to "premium",
17    "premium_annual:annual-base" to "premium",
18    // App Store products
19    "com.yourapp.premium.monthly" to "premium",
20    "com.yourapp.premium.annual" to "premium",
21)

いずれかのクライアントがエンタイトルメントを問い合わせた際、バックエンドは、そのエンタイトルメントがどのプラットフォームで発生したものであっても、ユーザーに有効なエンタイトルメントが存在するかどうかを確認します。

1// Backend endpoint
2fun getEntitlements(userId: String): EntitlementResponse {
3    val entitlements = entitlementRepository.findActiveByUserId(userId)
4
5    return EntitlementResponse(
6        entitlements = entitlements.map { entitlement ->
7            EntitlementInfo(
8                id = entitlement.entitlementId,
9                isActive = entitlement.isActive &&
10                    (entitlement.expiresAt?.isAfter(Instant.now()) ?: true),
11                expiresAt = entitlement.expiresAt,
12                sourcePlatform = entitlement.sourcePlatform,
13            )
14        }
15    )
16}

ステップ4:2系統の通知処理

エンタイトルメントをリアルタイムで同期し続けるためには、バックエンドが両プラットフォームからの通知を同時に処理できる必要があります。

1// Google Play RTDN handler
2fun handleGoogleNotification(message: PubSubMessage) {
3    val notification = parseDeveloperNotification(message)
4    val purchaseToken = notification.subscriptionNotification.purchaseToken
5
6    when (notification.subscriptionNotification.notificationType) {
7        NotificationType.SUBSCRIPTION_RENEWED -> refreshGoogleEntitlement(purchaseToken)
8        NotificationType.SUBSCRIPTION_CANCELED -> markGoogleEntitlementCanceled(purchaseToken)
9        NotificationType.SUBSCRIPTION_EXPIRED -> expireGoogleEntitlement(purchaseToken)
10        NotificationType.SUBSCRIPTION_REVOKED -> revokeGoogleEntitlement(purchaseToken)
11        // ... handle all notification types
12    }
13}
14
15// Apple Server Notification handler
16fun handleAppleNotification(signedPayload: String) {
17    val notification = verifyAndDecodeAppleNotification(signedPayload)
18    val transactionInfo = notification.data.signedTransactionInfo
19
20    when (notification.notificationType) {
21        "DID_RENEW" -> refreshAppleEntitlement(transactionInfo)
22        "DID_CHANGE_RENEWAL_STATUS" -> updateAppleRenewalStatus(transactionInfo)
23        "EXPIRED" -> expireAppleEntitlement(transactionInfo)
24        "REVOKE" -> revokeAppleEntitlement(transactionInfo)
25        // ... handle all notification types
26    }
27}

各通知ハンドラーは、イベント名も、ペイロード構造も、状態遷移(ステートマシン)の意味づけも異なります。グレース期間の扱いも異なります。返金フローも異なります。さらには「解約」という概念でさえ、両プラットフォーム間で微妙な違いがあります。

実際に必要となる工数の規模

クロスプラットフォームのサブスクリプション同期を構築するには、専用のインフラが必要になります。さらに、GoogleもAppleも課金システムを定期的にアップデートしています。Googleは2022年にbase planとofferを導入し、大幅なバックエンド変更が求められました。AppleはStoreKit 2をリリースし、まったく新しいトランザクションモデルを導入しました。こうした大きな変更があるたびに、インフラを適応させるためのエンジニアリング時間が必要になります。

専任のバックエンドエンジニアを抱える大規模チームであれば、これは対応可能かもしれません。しかし、両プラットフォーム向けにサブスクリプションアプリをリリースしようとしている小規模チームやインディー開発者にとっては、これはコアプロダクトとは直接関係のない数か月分の開発作業を意味します。

RevenueCatのアイデンティティシステム:自然な解決策

RevenueCatは、統一されたアイデンティティおよびエンタイトルメントシステムによって、クロスプラットフォーム問題を根本から解決します。前述のようなインフラを自前で構築する代わりに、RevenueCatがそれをサービスとして提供します。この仕組みを成立させている重要な設計判断が、app user IDという抽象化です。

アイデンティティシステムの仕組み

RevenueCat SDKを設定する際、独自のユーザーIDを指定することも、RevenueCatに匿名IDを生成させることもできます。

1// Android: Configure with your own user ID
2Purchases.configure(
3    PurchasesConfiguration.Builder(context, "your_revenuecat_api_key")
4        .appUserID("user_12345")
5        .build()
6)

iOS版は以下のような感じになります:

1// iOS: Configure with the same user ID
2Purchases.configure(
3    with: .builder(withAPIKey: "your_revenuecat_api_key")
4        .with(appUserID: "user_12345")
5        .build()
6)

両プラットフォームで同じ appUserID を使用すると、RevenueCatのバックエンド上に単一のサブスクライバーレコードが作成されます。ユーザーがどちらのプラットフォームでサブスク登録しても、RevenueCatがレシートを検証し、エンタイトルメントを記録し、そのユーザーIDに紐づけます。もう一方のプラットフォームのSDKが customer info を問い合わせると、他プラットフォーム由来のサブスクリプションも含めた完全なエンタイトルメント状態が返されます。

匿名ユーザーから認証ユーザーへの移行フロー

RevenueCatは、ユーザーが最初は匿名で利用し、後からアカウントを作成するという一般的なシナリオにも対応しています。ユーザーがアプリを初めて開いた際、RevenueCatは $RCAnonymousID:<uuid> という形式の匿名IDを生成します。アカウント作成前にサブスク登録した場合、そのサブスクリプションはこの匿名IDに紐づけられます。

その後、ユーザーがアカウントを作成してログインすると、RevenueCatの logIn メソッドによって、匿名ユーザーに紐づいていたすべての購入が、認証済みユーザーに移行されます。

1Purchases.sharedInstance.logIn(
2    newAppUserID = "user_12345",
3    callback = object : LogInCallback {
4        override fun onReceived(customerInfo: CustomerInfo, created: Boolean) {
5            // customerInfo now contains entitlements from:
6            // 1. Any previous purchases made under the anonymous ID
7            // 2. Any purchases previously associated with "user_12345"
8            // 3. Purchases from ANY platform linked to this user
9
10            val isPremium = customerInfo.entitlements["premium"]?.isActive == true
11        }
12
13        override fun onError(error: PurchasesError) {
14            // Handle error
15        }
16    }
17)

created ブール値は、そのユーザーが新規ユーザーか既存ユーザーかを示します。既存ユーザーである場合、RevenueCatは購入履歴をマージします。これはクロスプラットフォームのシナリオにおいて非常に重要です。たとえば、最初にiOSでサブスク登録し、その後Androidアプリをインストールしたユーザーでも、同じユーザーIDでログインすれば、エンタイトルメントが自動的に引き継がれます。

CustomerInfo:ひとつのオブジェクトで、すべてのプラットフォームを

CustomerInfo オブジェクトは、クロスプラットフォームのエンタイトルメント問題に対するRevenueCatの回答です。あらゆるプラットフォームのサブスクリプション状態を集約し、単一の、簡単に問い合わせ可能なオブジェクトとして提供します。

1Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
2    val premiumEntitlement = customerInfo.entitlements["premium"]
3
4    if (premiumEntitlement?.isActive == true) {
5        // User has premium access, regardless of which platform they subscribed on
6        val store = premiumEntitlement.store
7        // Could be Store.APP_STORE, Store.PLAY_STORE, Store.AMAZON, etc.
8
9        val expirationDate = premiumEntitlement.expirationDate
10        val willRenew = premiumEntitlement.willRenew
11
12        showPremiumContent()
13    } else {
14        showPaywall()
15    }
16}

各エンタイトルメントの store プロパティは、そのサブスクリプションがどのプラットフォーム由来かを示します。しかし、アクセスを付与する際にそれを確認する必要はありません。重要なのは isActive プロパティだけであり、これはすべてのプラットフォームで共通して機能します。

これが重要なポイントです。RevenueCatは、クロスプラットフォームのインフラ問題を、単一のプロパティチェックに変換します。AndroidアプリはAppleのレシート検証方法を知る必要がありません。iOSアプリもGoogle Playのpurchase tokenについて理解する必要はありません。これらはすべてRevenueCatのバックエンドが処理し、 CustomerInfo を通じて統一された状態を提供します。

裏側で何が起きているのか

ユーザーがiOSでサブスク登録し、その後Androidアプリを開いた場合、次のような流れになります。

  1. iOS SDKがApp StoreのレシートをRevenueCatのバックエンドに送信します。
  2. RevenueCatがAppleのサーバーでレシートを検証し、ユーザーのapp user IDに対してエンタイトルメントを記録します。
  3. 更新、解約、請求エラーなどを追跡するために、RevenueCatがApple Server Notificationsに登録します。
  4. Androidアプリが起動し、getCustomerInfo を呼び出すと、SDKは同じapp user IDをRevenueCatのバックエンドに送信します。
  5. RevenueCatは、iOS由来のサブスクリプションを含む完全なエンタイトルメント状態を返します。
  6. Androidアプリはプレミアムエンタイトルメントの isActive == true を確認し、アクセスを付与します。

すべての更新イベント、グレース期間、解約、期限切れはRevenueCat側でサーバー処理されます。両プラットフォームは、プラットフォーム固有のコードを書くことなく、常に最新のサブスクリプション状態を取得できます。

サブスクリプション管理の扱い

クロスプラットフォームのサブスクリプションで実務的に重要なのが、管理URLの振り分けです。iOSで登録したユーザーは、Google PlayではなくApp Store経由でサブスクリプションを管理する必要があります。RevenueCatはこれを managementURL プロパティで処理します。

1Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
2    val managementUrl = customerInfo.managementURL
3
4    // This URL points to the correct store based on where the user subscribed
5    // - App Store subscription settings for iOS purchases
6    // - Google Play subscription settings for Android purchases
7
8    showManageSubscriptionButton(managementUrl)
9}

これにより、ユーザーがGoogle Playからサブスクリプションを解約しようとしても見つからない、なぜなら実際の契約はApple側にある、といった混乱した状況を防ぐことができます。

開発スピードへの影響

クロスプラットフォーム同期を自前で構築する場合と、RevenueCatを利用する場合とでは、実装コストに大きな差があります。AndroidとiOSの両方でサブスクリプションアプリを提供するチームを想定して、両者を比較してみましょう。

RevenueCatを使わない場合

2つのレシート検証統合を含むバックエンドサーバー、2系統の通知処理パイプライン、ユーザーアイデンティティシステム、エンタイトルメントデータベース、そして両プラットフォームからバックエンドと通信するためのクライアント側コードを構築・維持する必要があります。初期開発だけで10〜18週間を要し、さらに両プラットフォームの進化に合わせた継続的なメンテナンスも必要になります。

RevenueCatを使う場合

実装は、各プラットフォームでSDKにapp user IDを設定し、 CustomerInfo を確認してアクティブなエンタイトルメントをチェックし、ペイウォールを表示するだけに簡略化されます。バックエンドのインフラはすべてRevenueCatが処理します。

1// The entire Android-side implementation for cross-platform entitlements
2class SubscriptionManager(private val context: Context) {
3
4    fun initialize(userId: String) {
5        Purchases.configure(
6            PurchasesConfiguration.Builder(context, "your_api_key")
7                .appUserID(userId)
8                .build()
9        )
10    }
11
12    fun checkAccess(onResult: (Boolean) -> Unit) {
13        Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
14            val isPremium = customerInfo.entitlements["premium"]?.isActive == true
15            onResult(isPremium)
16        }
17    }
18}

これが、Androidでクロスプラットフォームのサブスクリプションをサポートするために必要なコードのすべてです。iOS側のコードも同様に非常に簡潔です。バックエンドサーバーも、レシート検証も、通知処理も、エンタイトルメントデータベースも必要ありません。それらはすべてRevenueCatが管理します。

インディー開発者や小規模チームにとって、この違いは単なる時間短縮の問題ではありません。実現可能性そのものに関わる問題です。クロスプラットフォームのサブスクリプション基盤をゼロから構築するには、バックエンドの専門知識、サーバーホスティング、監視体制、そして継続的なメンテナンスが必要になります。多くの小規模チームにとって、こうした投資は現実的ではありません。その結果、クロスプラットフォーム対応自体を諦めるか、プラットフォームAPIの変更で簡単に壊れてしまう脆弱な仕組みを構築することになります。RevenueCatは、チームの規模を問わずクロスプラットフォームのサブスクリプションを実現可能にし、開発者が限られた時間を自社アプリを差別化する機能の開発に集中できるようにします。

結論

本記事では、クロスプラットフォームのサブスクリプション状態の管理が、なぜモバイルマネタイズにおいて最も難しい問題のひとつなのかを見てきました。Google Play BillingとAppleのStoreKitは、レシート形式、検証API、通知メカニズム、プロダクト構造に至るまで根本的に異なるシステムです。これらを橋渡しするには、統一されたアイデンティティシステム、二重のレシート検証、プラットフォームに依存しないエンタイトルメント保存、そして2系統の通知処理パイプラインが必要になります。

このインフラをゼロから構築するには数か月を要し、さらに両プラットフォームの進化に合わせた継続的なメンテナンスが求められます。大規模チームにとっては大きな投資ではあるものの対応可能な範囲かもしれません。しかし小規模チームやインディー開発者にとっては、コアプロダクト以上のエンジニアリング時間を消費しかねません。

RevenueCatのアイデンティティおよびエンタイトルメントシステムは、単一の CustomerInfo オブジェクトの背後にプラットフォーム差異を抽象化することで、この問題に対する自然な解決策を提供します。プラットフォーム間で共有されるapp user IDと、RevenueCatによるサーバーサイドのレシート検証および通知処理を組み合わせることで、数か月規模のインフラ構築プロジェクトは、数行のSDK設定に置き換わります。サブスクリプションがApp Store、Google Play、Amazon、あるいはWebのいずれで開始されたものであっても、アプリ側では isActive を確認してアクセスを付与するだけで済みます。

複数プラットフォームにまたがってユーザーに提供するサブスクリプションアプリを構築するチームにとって、この問題のスコープを理解することは、内製か外部サービス利用かという判断を行う上で重要です。マネージドソリューションを利用することで節約できた時間は、アプリの改善、ペイウォールの最適化、そしてプロダクトを真に差別化する機能の開発に振り向けることができます。