---
title: "Cross-platform subscription state: sharing entitlements between Android and iOS"
description: "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."
language: "en"
publishedAt: "2026-02-24T02:49:36Z"
updatedAt: "2026-02-24T02:49:36Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
readingTime: 12
canonical: "https://www.revenuecat.com/blog/engineering/cross-platform-subscription"
---

# Cross-platform subscription state: sharing entitlements between Android and iOS

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.

## Table of contents

- [The fundamental problem: One user, two ecosystems](#the-fundamental-problem-one-user-two-ecosystems)
- [Two billing systems, zero interoperability](#two-billing-systems-zero-interoperability)
  - [Receipt formats and verification](#receipt-formats-and-verification)
  - [Real-time notifications](#real-time-notifications)
  - [Product configuration](#product-configuration)
- [Building cross platform sync yourself](#building-cross-platform-sync-yourself)
  - [Step 1: Unified user identity](#step-1-unified-user-identity)
  - [Step 2: Dual receipt verification](#step-2-dual-receipt-verification)
  - [Step 3: Unified entitlement storage](#step-3-unified-entitlement-storage)
  - [Step 4: Dual notification processing](#step-4-dual-notification-processing)
  - [The true scope of effort](#the-true-scope-of-effort)
- [RevenueCat’s identity system: The natural solution](#revenuecats-identity-system-the-natural-solution)
  - [How the identity system works](#how-the-identity-system-works)
  - [Anonymous to identified user flow](#anonymous-to-identified-user-flow)
  - [CustomerInfo: One object, all platforms](#customerinfo-one-object-all-platforms)
  - [What happens behind the scenes](#what-happens-behind-the-scenes)
  - [Handling subscription management](#handling-subscription-management)
- [The impact on development velocity](#the-impact-on-development-velocity)
  - [Without RevenueCat](#without-revenuecat)
  - [With RevenueCat](#with-revenuecat)
- [Conclusion](#conclusion)

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](https://www.revenuecat.com/docs/customers/identifying-customers) 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.

```kotlin
\/\/ On the Android side, this returns nothing
val params = QueryPurchasesParams.newBuilder()
    .setProductType(BillingClient.ProductType.SUBS)
    .build()

billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
    \/\/ purchases is empty because the user subscribed through Apple
    \/\/ The Android app has no way to know about the iOS subscription
    if (purchases.isEmpty()) {
        showPaywall() \/\/ User sees this despite having an active subscription
    }
}
```

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.

| Aspect | Google Play Billing | Apple StoreKit |
| --- | --- | --- |
| **Purchase proof** | Purchase token (opaque string) | Signed receipt (JWS in StoreKit 2) |
| **Verification endpoint** | `purchases.subscriptionsv2.get` REST API | App Store Server API (`/inApps/v1/subscriptions`) |
| **Authentication** | Google service account with JSON key | JWT signed with App Store Connect private key |
| **Response format** | `SubscriptionPurchaseV2` JSON object | `JWSTransactionDecodedPayload` (signed JSON) |
| **Subscription ID format** | `productId:basePlanId` | Simple `productId` string |
| **Renewal tracking** | `expiryTime` field on subscription resource | `expiresDate` 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.

| Aspect | Google Play RTDN | Apple Server Notifications V2 |
| --- | --- | --- |
| **Delivery mechanism** | Google Cloud Pub/Sub | HTTPS POST to your endpoint |
| **Notification format** | `DeveloperNotification` with type enum | `signedPayload` (JWS) with `notificationType` |
| **Event types** | `SUBSCRIPTION_RENEWED`, `SUBSCRIPTION_CANCELED`, etc. | `DID_RENEW`, `DID_CHANGE_RENEWAL_STATUS`, etc. |
| **User identification** | `purchaseToken` in notification | `originalTransactionId` in signed payload |
| **Setup** | Configure Pub/Sub topic in Play Console | Register 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.

```kotlin
\/\/ Android client: associate purchase with your user account
fun postPurchaseToBackend(purchase: Purchase, userId: String) {
    val request = PurchaseVerificationRequest(
        platform = "android",
        purchaseToken = purchase.purchaseToken,
        productId = purchase.products.first(),
        userId = userId,
    )

    backendApi.verifyAndRecordPurchase(request)
}
```

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

```swift
\/\/ iOS client: associate purchase with your user account
func postPurchaseToBackend(transaction: Transaction, userId: String) async {
    let request = PurchaseVerificationRequest(
        platform: "ios",
        transactionId: String(transaction.originalID),
        productId: transaction.productID,
        userId: userId
    )

    await backendAPI.verifyAndRecordPurchase(request)
}
```

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:

```kotlin
\/\/ Backend: platform-specific verification
class PurchaseVerifier(
    private val playDeveloperApi: AndroidPublisher,
    private val appStoreServerApi: AppStoreServerAPIClient,
) {
    suspend fun verify(request: PurchaseVerificationRequest): VerificationResult {
        return when (request.platform) {
            "android" -> verifyGooglePurchase(request)
            "ios" -> verifyApplePurchase(request)
            else -> VerificationResult.InvalidPlatform
        }
    }

    private suspend fun verifyGooglePurchase(
        request: PurchaseVerificationRequest,
    ): VerificationResult {
        val subscription = playDeveloperApi
            .purchases()
            .subscriptionsv2()
            .get(packageName, request.purchaseToken)
            .execute()

        return if (subscription.subscriptionState == "SUBSCRIPTION_STATE_ACTIVE") {
            VerificationResult.Valid(
                expiryTime = subscription.lineItems[0].expiryTime,
                productId = subscription.lineItems[0].productId,
                platform = "android",
            )
        } else {
            VerificationResult.Expired
        }
    }

    private suspend fun verifyApplePurchase(
        request: PurchaseVerificationRequest,
    ): VerificationResult {
        \/\/ Uses Apple's App Store Server API
        val statusResponse = appStoreServerApi
            .getAllSubscriptionStatuses(request.transactionId)

        val activeSubscription = statusResponse.data
            .flatMap { it.lastTransactions }
            .find { it.status == Status.ACTIVE }

        return if (activeSubscription != null) {
            val transactionInfo = activeSubscription.signedTransactionInfo
            VerificationResult.Valid(
                expiryTime = transactionInfo.expiresDate,
                productId = transactionInfo.productId,
                platform = "ios",
            )
        } else {
            VerificationResult.Expired
        }
    }
}
```

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:

```kotlin
\/\/ Backend entitlement model
data class UserEntitlement(
    val userId: String,
    val entitlementId: String,         \/\/ e.g., "premium"
    val isActive: Boolean,
    val sourcePlatform: String,        \/\/ "android" or "ios"
    val platformProductId: String,     \/\/ Platform-specific product ID
    val platformPurchaseToken: String, \/\/ Platform-specific purchase proof
    val expiresAt: Instant?,
    val lastVerifiedAt: Instant,
)

\/\/ Product mapping configuration
val productToEntitlementMap = mapOf(
    \/\/ Google Play products
    "premium_monthly:monthly-base" to "premium",
    "premium_annual:annual-base" to "premium",
    \/\/ App Store products
    "com.yourapp.premium.monthly" to "premium",
    "com.yourapp.premium.annual" to "premium",
)
```

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

```kotlin
\/\/ Backend endpoint
fun getEntitlements(userId: String): EntitlementResponse {
    val entitlements = entitlementRepository.findActiveByUserId(userId)

    return EntitlementResponse(
        entitlements = entitlements.map { entitlement ->
            EntitlementInfo(
                id = entitlement.entitlementId,
                isActive = entitlement.isActive &&
                    (entitlement.expiresAt?.isAfter(Instant.now()) ?: true),
                expiresAt = entitlement.expiresAt,
                sourcePlatform = entitlement.sourcePlatform,
            )
        }
    )
}
```

### **Step 4: Dual notification processing**

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

```kotlin
\/\/ Google Play RTDN handler
fun handleGoogleNotification(message: PubSubMessage) {
    val notification = parseDeveloperNotification(message)
    val purchaseToken = notification.subscriptionNotification.purchaseToken

    when (notification.subscriptionNotification.notificationType) {
        NotificationType.SUBSCRIPTION_RENEWED -> refreshGoogleEntitlement(purchaseToken)
        NotificationType.SUBSCRIPTION_CANCELED -> markGoogleEntitlementCanceled(purchaseToken)
        NotificationType.SUBSCRIPTION_EXPIRED -> expireGoogleEntitlement(purchaseToken)
        NotificationType.SUBSCRIPTION_REVOKED -> revokeGoogleEntitlement(purchaseToken)
        \/\/ ... handle all notification types
    }
}

\/\/ Apple Server Notification handler
fun handleAppleNotification(signedPayload: String) {
    val notification = verifyAndDecodeAppleNotification(signedPayload)
    val transactionInfo = notification.data.signedTransactionInfo

    when (notification.notificationType) {
        "DID_RENEW" -> refreshAppleEntitlement(transactionInfo)
        "DID_CHANGE_RENEWAL_STATUS" -> updateAppleRenewalStatus(transactionInfo)
        "EXPIRED" -> expireAppleEntitlement(transactionInfo)
        "REVOKE" -> revokeAppleEntitlement(transactionInfo)
        \/\/ ... handle all notification types
    }
}
```

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:

```kotlin
\/\/ Android: Configure with your own user ID
Purchases.configure(
    PurchasesConfiguration.Builder(context, "your_revenuecat_api_key")
        .appUserID("user_12345")
        .build()
)
```

For iOS will be like so:

```swift
\/\/ iOS: Configure with the same user ID
Purchases.configure(
    with: .builder(withAPIKey: "your_revenuecat_api_key")
        .with(appUserID: "user_12345")
        .build()
)
```

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:

```kotlin
Purchases.sharedInstance.logIn(
    newAppUserID = "user_12345",
    callback = object : LogInCallback {
        override fun onReceived(customerInfo: CustomerInfo, created: Boolean) {
            \/\/ customerInfo now contains entitlements from:
            \/\/ 1. Any previous purchases made under the anonymous ID
            \/\/ 2. Any purchases previously associated with "user_12345"
            \/\/ 3. Purchases from ANY platform linked to this user

            val isPremium = customerInfo.entitlements["premium"]?.isActive == true
        }

        override fun onError(error: PurchasesError) {
            \/\/ Handle error
        }
    }
)
```

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:

```kotlin
Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
    val premiumEntitlement = customerInfo.entitlements["premium"]

    if (premiumEntitlement?.isActive == true) {
        \/\/ User has premium access, regardless of which platform they subscribed on
        val store = premiumEntitlement.store
        \/\/ Could be Store.APP_STORE, Store.PLAY_STORE, Store.AMAZON, etc.

        val expirationDate = premiumEntitlement.expirationDate
        val willRenew = premiumEntitlement.willRenew

        showPremiumContent()
    } else {
        showPaywall()
    }
}
```

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.
1. RevenueCat verifies the receipt with Apple’s servers and records the entitlement against the user’s app user ID.
1. RevenueCat registers for Apple Server Notifications to track renewals, cancellations, and billing issues.
1. When the Android app launches and calls `getCustomerInfo`, the SDK sends the same app user ID to RevenueCat’s backend.
1. RevenueCat returns the complete entitlement state, including the iOS subscription.
1. 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:

```kotlin
Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
    val managementUrl = customerInfo.managementURL

    \/\/ This URL points to the correct store based on where the user subscribed
    \/\/ - App Store subscription settings for iOS purchases
    \/\/ - Google Play subscription settings for Android purchases

    showManageSubscriptionButton(managementUrl)
}
```

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.

```kotlin
\/\/ The entire Android-side implementation for cross-platform entitlements
class SubscriptionManager(private val context: Context) {

    fun initialize(userId: String) {
        Purchases.configure(
            PurchasesConfiguration.Builder(context, "your_api_key")
                .appUserID(userId)
                .build()
        )
    }

    fun checkAccess(onResult: (Boolean) -> Unit) {
        Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
            val isPremium = customerInfo.entitlements["premium"]?.isActive == true
            onResult(isPremium)
        }
    }
}
```

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.

---

## Related posts

- [Understanding Google Play subscription proration: a developer’s guide](https://www.revenuecat.com/blog/engineering/google-proration)
- [Google Play’s subscription with Add-ons: guide to multi-line subscriptions](https://www.revenuecat.com/blog/engineering/subscription-add-ons)
- [Server-driven UI SDK on Android: how RevenueCat enables remote paywalls without app updates](https://www.revenuecat.com/blog/engineering/server-driven-android)
