Subscription apps rarely live on a single platform. A user might subscribe on their iPhone during their morning commute, then open your Android tablet app at home expecting full access. This expectation is intuitive from the user’s perspective, they paid for a subscription, so it should work everywhere. But from a developer’s perspective, making this work is one of the hardest problems in subscription infrastructure. Google Play Billing and Apple’s StoreKit are entirely separate systems with different receipt formats, different verification mechanisms, different notification systems, and fundamentally different assumptions about how purchases are represented. There is no built in interoperability between them.

In this article, you’ll explore why cross platform subscription state is so difficult to implement, examine the fundamental incompatibilities between Google Play Billing and StoreKit, walk through what it takes to build cross platform entitlement sync from scratch, and see how RevenueCat’s identity system provides a natural solution that dramatically reduces the engineering effort required, especially for small teams and indie developers.

The fundamental problem: One user, two ecosystems

Consider a fitness app with a “Premium” subscription. A user subscribes through the App Store on their iPhone. A week later, they buy an Android tablet and download your app. They log in with the same account and expect to see their premium features. What actually happens?

Without cross platform infrastructure, the Android app has no idea this user has an active subscription. Google Play Billing only knows about purchases made through Google Play. The App Store receipt sitting on Apple’s servers is invisible to the Android app. The user sees a paywall asking them to subscribe again, even though they are already paying.

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}

This is not a bug. It is the expected behavior. Each billing system operates in isolation, and bridging them requires significant infrastructure that neither platform provides.

Two billing systems, zero interoperability

To understand why cross platform sync is so difficult, you need to understand how differently Google and Apple represent purchases. These are not minor API differences. They are fundamentally different architectures.

Receipt formats and verification

Apple and Google use entirely different mechanisms to prove that a purchase happened.

AspectGoogle Play BillingApple StoreKit
Purchase proofPurchase token (opaque string)Signed receipt (JWS in StoreKit 2)
Verification endpointpurchases.subscriptionsv2.get REST APIApp Store Server API (/inApps/v1/subscriptions)
AuthenticationGoogle service account with JSON keyJWT signed with App Store Connect private key
Response formatSubscriptionPurchaseV2 JSON objectJWSTransactionDecodedPayload (signed JSON)
Subscription ID formatproductId:basePlanIdSimple productId string
Renewal trackingexpiryTime field on subscription resourceexpiresDate in transaction info

Google Play uses a purchase token model. When a user subscribes, your app receives a purchase token. You send this token to the Google Play Developer API, which returns the current subscription state. The token is an opaque string with no inherent meaning.

Apple uses a signed transaction model. In StoreKit 2, purchase information is delivered as JSON Web Signatures (JWS) that your server can verify using Apple’s public key. Each transaction is a self contained, cryptographically signed record.

These are not just different APIs wrapping the same concept. They represent different philosophies about where trust lives. Google says “ask our server, we’ll tell you the state.” Apple says “here’s a cryptographically signed proof, verify it yourself.”

Real-time notifications

Both platforms offer server-to-server notifications for subscription events, but the notification systems differ substantially.

AspectGoogle Play RTDNApple Server Notifications V2
Delivery mechanismGoogle Cloud Pub/SubHTTPS POST to your endpoint
Notification formatDeveloperNotification with type enumsignedPayload (JWS) with notificationType
Event typesSUBSCRIPTION_RENEWEDSUBSCRIPTION_CANCELED, etc.DID_RENEWDID_CHANGE_RENEWAL_STATUS, etc.
User identificationpurchaseToken in notificationoriginalTransactionId in signed payload
SetupConfigure Pub/Sub topic in Play ConsoleRegister URL in App Store Connect

Google delivers notifications through Cloud Pub/Sub, requiring you to set up a Pub/Sub subscription and a processing service. Apple sends HTTPS POST requests directly to a URL you configure. The event names differ, the payload structures differ, and the information included in each notification type differs.

This means your backend needs two completely separate notification processing pipelines with different authentication, different parsing logic, and different state machine interpretations.

Product configuration

Even the way you define subscription products differs between platforms.

Google Play introduced base plans and offers in 2022, creating a hierarchical product model: a subscription contains one or more base plans, each of which can have multiple offers with different pricing phases. A single subscription product ID can have monthly and annual base plans, introductory offers, and promotional pricing, all configured through the Play Console.

Apple’s product model is flatter. Each product ID in App Store Connect represents a single subscription with a single duration. To offer both monthly and annual options, you create two separate product IDs and group them in a subscription group. Introductory offers and promotional offers are configured per product, not as nested objects.

This structural difference means there is no one to one mapping between a Google Play subscription product and an Apple subscription product. Your backend must maintain a mapping layer that translates platform specific product identifiers into a unified entitlement concept.

Building cross platform sync yourself

If you decide to build cross platform subscription sync without a third party service, here is what the architecture looks like. Understanding this effort is important even if you ultimately choose a managed solution, because it reveals why the problem is genuinely hard.

Step 1: Unified user identity

The first requirement is a user identity system that works across platforms. Each platform has its own notion of a user, but neither knows about the other. You need a server side user account that both platforms can associate purchases with.

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}

For iOS, there will not be much difference from the Android side:

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}

Both clients send purchase data to your backend, tagged with the same userId. Your backend must verify each purchase against the correct platform’s API and record the entitlement against the unified user account.

Step 2: Dual receipt verification

Your backend needs to verify purchases from both platforms, which means integrating with two completely different verification APIs:

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}

Each verification path has its own authentication setup, error handling, and response parsing. Google requires a service account credential. Apple requires a JWT signed with a private key from App Store Connect. The response formats share no common structure.

Step 3: Unified entitlement storage

Your backend needs a data model that maps platform-specific products to platform-agnostic entitlements:

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)

When either client queries for entitlements, your backend checks whether the user has any active entitlement, regardless of which platform it originated from:

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}

Step 4: Dual notification processing

To keep entitlements in sync in real time, your backend must process notifications from both platforms simultaneously:

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}

Each notification handler has different event names, different payload structures, and different state machine semantics. Grace periods work differently. Refund flows work differently. Even the concept of “cancellation” has subtle differences between the two platforms.

The true scope of effort

You need an infrastructure, building a cross-platform subscription sync involves. Even both Google and Apple regularly update their billing systems. Google introduced base plans and offers in 2022, requiring significant backend changes. Apple launched StoreKit 2 with an entirely new transaction model. Each major update requires engineering time to adapt your infrastructure.

For a large team with dedicated backend engineers, this might be manageable. For a small team or an indie developer trying to ship a subscription app on both platforms, this represents months of work that has nothing to do with the core product.

RevenueCat’s identity system: The natural solution

RevenueCat solves the cross platform problem at its foundation through a unified identity and entitlement system. Rather than requiring you to build the infrastructure described above, RevenueCat provides it as a service. The key design decision that makes this work is the app user ID abstraction.

How the identity system works

When you configure the RevenueCat SDK, you can either provide your own user ID or let RevenueCat generate an anonymous one:

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)

For iOS will be like so:

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)

The same appUserID on both platforms creates a single subscriber record in RevenueCat’s backend. When the user subscribes on either platform, RevenueCat verifies the receipt, records the entitlement, and associates it with this user ID. When the other platform’s SDK queries for customer info, it receives the complete entitlement state, including subscriptions from the other platform.

Anonymous to identified user flow

RevenueCat also handles the common scenario where users start anonymously and later create an account. When a user first opens your app, RevenueCat generates an anonymous ID in the format $RCAnonymousID:<uuid>. If the user subscribes before creating an account, the subscription is associated with this anonymous ID.

When the user later creates an account and logs in, RevenueCat’s logIn method transfers all purchases from the anonymous user to the identified user:

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)

The created boolean indicates whether this was a new user or an existing one. If it is an existing user, RevenueCat merges the purchase histories. This is critical for cross platform scenarios: a user who first subscribed on iOS and later installs the Android app gets their entitlements transferred automatically when they log in with the same user ID.

CustomerInfo: One object, all platforms

The CustomerInfo object is RevenueCat’s answer to the cross platform entitlement problem. It aggregates subscription state from every platform into a single, easy to query object:

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}

The store property on each entitlement tells you which platform the subscription originated from. But for granting access, you do not need to check it. The isActive property is the only thing that matters, and it works across all platforms.

This is the key insight: RevenueCat transforms a cross platform infrastructure problem into a single property check. Your Android app does not need to know how to verify Apple receipts. Your iOS app does not need to know about Google Play purchase tokens. RevenueCat’s backend handles all of that and presents a unified view through CustomerInfo.

What happens behind the scenes

When a user subscribes on iOS and later opens the Android app, the following sequence occurs:

  1. The iOS SDK posts the App Store receipt to RevenueCat’s backend.
  2. RevenueCat verifies the receipt with Apple’s servers and records the entitlement against the user’s app user ID.
  3. RevenueCat registers for Apple Server Notifications to track renewals, cancellations, and billing issues.
  4. When the Android app launches and calls getCustomerInfo, the SDK sends the same app user ID to RevenueCat’s backend.
  5. RevenueCat returns the complete entitlement state, including the iOS subscription.
  6. The Android app sees isActive == true on the premium entitlement and grants access.

All renewal events, grace periods, cancellations, and expirations are tracked server side by RevenueCat. Both platforms always see the current subscription state without any platform specific code.

Handling subscription management

One practical detail that cross platform subscriptions introduce is management URL routing. A user who subscribed on iOS needs to manage their subscription through the App Store, not Google Play. RevenueCat handles this through the managementURL property:

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}

This prevents the confusing scenario where a user tries to cancel their subscription through Google Play but cannot find it because the subscription lives on Apple’s side.

The impact on development velocity

The difference in implementation effort between building cross platform sync yourself and using RevenueCat is substantial. Let’s compare the two approaches for a team shipping a subscription app on both Android and iOS.

Without RevenueCat

You need to build and maintain: a backend server with two receipt verification integrations, two notification processing pipelines, a user identity system, an entitlement database, and client side code on both platforms to communicate with your backend. This is 10 to 18 weeks of initial development, plus ongoing maintenance as both platforms evolve.

With RevenueCat

Your implementation reduces to: configure the SDK with an app user ID on each platform, check CustomerInfo for active entitlements, and display paywalls. The backend infrastructure is handled entirely by 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}

This is the complete code needed to support cross platform subscriptions on Android. The equivalent iOS code is similarly concise. No backend server, no receipt verification, no notification processing, no entitlement database. RevenueCat manages all of it.

For indie developers and small teams, this difference is not just about saving time. It is about feasibility. Building cross platform subscription infrastructure from scratch requires backend engineering expertise, server hosting, monitoring, and ongoing maintenance. Many small teams simply cannot afford this investment, which means they either skip cross platform support entirely or build something fragile that breaks when platform APIs change. RevenueCat makes cross platform subscriptions accessible to teams of any size, letting developers focus their limited time on the features that make their app unique.

Conclusion

In this article, you’ve explored why cross platform subscription state is one of the hardest problems in mobile monetization. Google Play Billing and Apple’s StoreKit are fundamentally different systems with incompatible receipt formats, different verification APIs, different notification mechanisms, and different product structures. Bridging them requires a unified identity system, dual receipt verification, platform agnostic entitlement storage, and two parallel notification processing pipelines.

Building this infrastructure from scratch takes months and requires continuous maintenance as both platforms evolve. For large teams, it is a significant but manageable investment. For small teams and indie developers, it can consume more engineering time than the core product itself.

RevenueCat’s identity and entitlement system provides a natural solution by abstracting away the platform differences behind a single CustomerInfo object. A shared app user ID across platforms, combined with RevenueCat’s server side receipt verification and notification processing, transforms a months long infrastructure project into a few lines of SDK configuration. Whether a subscription originated from the App Store, Google Play, Amazon, or the web, your app simply checks isActive and grants access.

For teams building subscription apps that serve users across platforms, understanding the scope of this problem helps you make informed build versus buy decisions. The time saved by using a managed solution can be redirected toward improving your app, optimizing your paywall, and building the features that actually differentiate your product.