You ship a subscription app on Android and your team starts the iOS port. Suddenly you are maintaining two paywall implementations for Android and iOS, two billing integrations, and two sets of receipt verification code with different APIs and different bugs. RevenueCat’s purchases-kmp SDK collapses that duplication. You write your subscription logic once in commonMain, the SDK wraps Google Play Billing on Android and StoreKit on iOS, and a Compose Multiplatform paywall component renders the same UI on both platforms.

In this article, you’ll set up a Kotlin Multiplatform project with the RevenueCat KMP SDK, configure dashboard products and entitlements, initialize Purchases on Android and iOS, gate premium content from common code, run an in app purchase from commonMain, and drop in a server driven paywall built with the dashboard’s Paywall Editor. You’ll work directly with the same source layout used by cat-paywalls-kmp, the official KMP demo app.

What you’ll build

You’ll end up with a Compose Multiplatform app that lists premium articles, fades the body until the user is entitled, opens a server driven paywall, runs the purchase through the platform native dialog, and refreshes the entitlement state. The same screen runs on both iPhone and a Pixel without a single line of duplicated UI code.

The repository structure is a normal multi module KMP project: a composeApp module with commonMainandroidMain, and iosMain source sets, a few feature modules (home, article, paywalls, subscriptions), and core modules for data, network, and design system. Every line of subscription logic in this tutorial lives in commonMain.

Why single codebase changes the math

Building cross-platform subscriptions without a shared SDK means writing every subscription concept twice. You query Google Play with BillingClient and a purchase token, and you query StoreKit with Product.products(for:) and a signed JWS. You verify receipts with two completely separate server APIs, store them in two different shapes, and reconcile them with a backend mapping layer. None of this work is the part of your product that users care about.

The RevenueCat KMP SDK gives you four shared concepts that hide all of that:

  • Offerings are the set of products you currently sell, configured in the dashboard rather than in your app.
  • Packages are the buyable units inside an offering (monthly, annual, lifetime).
  • Entitlements are the access levels your app cares about (premiumprofamily), independent of which store the user paid through.
  • CustomerInfo is a single object that aggregates every active entitlement for the current user, regardless of platform.

Once you have these four objects, your app stops asking “did this user buy through Google Play or App Store?” and starts asking “is customerInfo.entitlements["premium"] active?” That single property check works the same on iOS and Android.

Prerequisites and dashboard setup

Before any code, you’ll set up four things in the RevenueCat dashboard:

  1. project with one Android app and one iOS app, each linked to its store credentials.
  2. Products imported from Google Play Console and App Store Connect.
  3. An entitlement named premium (or whatever identifier you prefer).
  4. An offering with one or more packages attached, marked as the current offering.

Two codelabs walk through the dashboard side end to end. If you have not configured products yet, go through them first:

The two important screens to land on are the entitlement and the offering. The entitlement is the unit your app code checks:

The offering is what your app fetches at runtime. It bundles the packages you want this version of the app to display, and you can change its contents without shipping a new build:

After both apps are set up, copy the public SDK API keys from Project Settings > API Keys. There is one key per platform, and you will use both of them in Step 2.

Step 1: Add the RevenueCat KMP SDK

The KMP SDK ships as two artifacts: purchases-kmp-core for the subscription logic and purchases-kmp-ui for the Compose Multiplatform paywall component. Both are published to Maven Central with a single version coordinate of the form <sdk>+<hybrid-common>. The number after the plus sign matters when you link the iOS pod, so do not strip it.

Start by adding the version and library entries to gradle/libs.versions.toml:

1[versions]
2purchases-kmp = "2.2.14+17.24.0"
3
4[libraries]
5purchases-kmp-core = { module = "com.revenuecat.purchases:purchases-kmp-core", version.ref = "purchases-kmp" }
6purchases-kmp-ui   = { module = "com.revenuecat.purchases:purchases-kmp-ui",   version.ref = "purchases-kmp" }

Then declare the dependencies inside the commonMain source set of your app module’s build.gradle.kts. In the cat-paywalls-kmp demo this lives in composeApp/build.gradle.kts:

1kotlin {
2  sourceSets {
3    commonMain.dependencies {
4      // RevenueCat
5      implementation(libs.purchases.kmp.core)
6      // Compose Multiplatform paywall component
7      implementation(libs.purchases.kmp.ui)
8    }
9  }
10}

Notice that both Android and iOS pull the same commonMain dependency. There is no androidMain.dependencies { implementation("com.revenuecat...") } block. The KMP artifact carries platform specific bindings inside it.

Linking the iOS native framework

The KMP SDK depends on PurchasesHybridCommon, a native iOS framework that wraps StoreKit. The cleanest way to bring it in is through the Kotlin CocoaPods plugin. Apply the plugin to your composeApp module:

1plugins {
2  alias(libs.plugins.kotlin.multiplatform)
3  alias(libs.plugins.compose.multiplatform)
4  alias(libs.plugins.compose.compiler)
5  kotlin("native.cocoapods")
6}

Then declare both pods inside the kotlin { cocoapods { ... } } block. Pin the pod version to the same major as your purchases-kmp artifact so the Kotlin and Swift sides agree on protocol shapes:

1kotlin {
2  cocoapods {
3    summary = "Cat Paywalls KMP App"
4    homepage = "<https://github.com/revenuecat/cat-paywalls-kmp>"
5    version = "1.0"
6    ios.deploymentTarget = "15.0"
7    podfile = project.file("../iosApp/Podfile")
8
9    framework {
10      baseName = "ComposeApp"
11      isStatic = true
12    }
13
14    pod("RevenueCat") {
15      version = "~> 5.21"
16      extraOpts += listOf("-compiler-option", "-fmodules")
17    }
18    pod("RevenueCatUI") {
19      version = "~> 5.21"
20      extraOpts += listOf("-compiler-option", "-fmodules")
21    }
22  }
23}

Run ./gradlew podInstall once. Gradle generates a .podspec for your shared module and writes a Podfile.lock next to your iOS app. From now on, opening the iOS workspace in Xcode pulls everything down through CocoaPods.

The KMP SDK uses Kotlin/Native interop bindings that are still flagged as experimental, so opt in inside any iOS source set that touches them:

1kotlin {
2  sourceSets {
3    all {
4      languageSettings {
5        if (name.startsWith("ios")) {
6          optIn("kotlinx.cinterop.ExperimentalForeignApi")
7        }
8      }
9    }
10  }
11}

That is the entire setup. No platform-specific source files yet. Everything else lives in commonMain.

Step 2: Initialize Purchases on each platform

Purchases is a singleton. You configure it once early in the app lifecycle, then every other call goes through Purchases.sharedInstance. Initialization is the only place where Android and iOS code differ, and only because each platform has its own application entry point.

On Android, initialize from your Application.onCreate. The cat-paywalls-kmp demo does this in CatArticlesApplication:

1class CatArticlesApplication : Application() {
2
3  override fun onCreate() {
4    super.onCreate()
5
6    Purchases.logLevel = LogLevel.DEBUG
7    Purchases.configure(
8      PurchasesConfiguration(apiKey = REVENUECAT_ANDROID_API_KEY) {
9        appUserId = null // Anonymous user
10      },
11    )
12  }
13
14  companion object {
15    private const val REVENUECAT_ANDROID_API_KEY = "your_android_api_key"
16  }
17}

The PurchasesConfiguration(apiKey) { ... } builder is a small DSL. Inside the trailing lambda you can set appUserIdpurchasesAreCompletedByverificationMode, and other options. Passing appUserId = null tells the SDK to generate an anonymous identifier in the form $RCAnonymousID:<uuid>. When the user later signs in to your backend, you call Purchases.sharedInstance.logIn(userId) and RevenueCat transfers any purchases to that account.

Do not forget to register the Application class and the INTERNET permission in AndroidManifest.xml:

1<application
2    android:name=".CatArticlesApplication"
3    android:label="@string/app_name">
4    <activity android:name=".MainActivity" android:exported="true">
5        <intent-filter>
6            <action android:name="android.intent.action.MAIN" />
7            <category android:name="android.intent.category.LAUNCHER" />
8        </intent-filter>
9    </activity>
10</application>
11
12<uses-permission android:name="android.permission.INTERNET" />

On iOS, initialize from your SwiftUI App struct. You import the native RevenueCat pod here, not the KMP wrapper, because configuration runs in Swift before the Kotlin runtime starts:

1import SwiftUI
2import RevenueCat
3
4@main
5struct iosAppApp: App {
6
7    init() {
8        Purchases.logLevel = .debug
9        Purchases.configure(withAPIKey: "your_ios_api_key")
10    }
11
12    var body: some Scene {
13        WindowGroup {
14            ContentView()
15        }
16    }
17}

ContentView then hosts the shared Compose surface inside a UIViewControllerRepresentable:

1struct ComposeView: UIViewControllerRepresentable {
2    func makeUIViewController(context: Context) -> UIViewController {
3        MainViewControllerKt.MainViewController()
4    }
5
6    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
7}

The Kotlin side of that bridge is a one liner in iosMain:

1fun MainViewController(): UIViewController {
2  val appGraph = createGraph<AppGraph>()
3  return ComposeUIViewController {
4    App(appGraph = appGraph)
5  }
6}

App is the same @Composable you call from MainActivity on Android. From this point on, every screen you build runs on both platforms.

The reason initialization is split is that Purchases.configure reaches into the platform billing client immediately. On Android it asks BillingClient to open a connection. On iOS it registers a StoreKit transaction listener. Both need to happen before any Compose composition starts, which is why you do not configure inside commonMain.

Step 3: Check entitlements from common code

Once Purchases is configured, you can ask “is the current user entitled to premium?” from anywhere in commonMain. The KMP SDK exposes coroutine friendly suspend variants of every callback API. The one you want here is awaitCustomerInfo():

1suspend fun isPremium(): Boolean {
2  val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
3  return customerInfo.entitlements["premium"]?.isActive == true
4}

CustomerInfo.entitlements is a map keyed by the entitlement identifier you set up in the dashboard. The value is an EntitlementInfo with an isActive flag, an expirationDate, a willRenew boolean, and a store enum that tells you which platform the underlying purchase came from. For access decisions, only isActive matters. The entitlement is active whether the user subscribed through the App Store, Google Play, Stripe, or a promotional grant from your support team.

Most apps wrap this in a repository so the rest of the app can collect a Flow and forget about callbacks. The cat-paywalls-kmp demo defines PaywallsRepository in core/data:

1interface PaywallsRepository {
2  fun fetchOffering(): Flow<Result<Offering>>
3  fun fetchCustomerInfo(): Flow<Result<CustomerInfo>>
4  fun awaitPurchase(packageId: String): Flow<Result<StoreTransaction>>
5}

The fetchCustomerInfo implementation is a thin Flow wrapper around the SDK call, with Dispatchers.IO to keep network work off the main thread:

1override fun fetchCustomerInfo(): Flow<Result<CustomerInfo>> = flow {
2  try {
3    val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
4    emit(Result.success(customerInfo))
5  } catch (e: Exception) {
6    emit(Result.failure(e))
7  }
8}.flowOn(Dispatchers.IO)

A ViewModel collects this flow and exposes a StateFlow<CustomerInfo?>:

1class CatArticlesDetailViewModel(
2  articleId: Long,
3  articlesRepository: ArticlesRepository,
4  paywallsRepository: PaywallsRepository,
5) : ViewModel() {
6
7  val customerInfo: StateFlow<CustomerInfo?> =
8    paywallsRepository.fetchCustomerInfo()
9      .map { it.getOrNull() }
10      .stateIn(
11        scope = viewModelScope,
12        started = SharingStarted.WhileSubscribed(5000),
13        initialValue = null,
14      )
15}

The Compose Multiplatform UI then reads it like any other state and decides whether to render the article body or fade it behind a paywall prompt:

1private const val ENTITLEMENT_PREMIUM = "premium"
2
3@Composable
4private fun CatArticlesDetailContent(
5  article: Article,
6  viewModel: CatArticlesDetailViewModel,
7  navigateToPaywalls: () -> Unit,
8) {
9  val customerInfo by viewModel.customerInfo.collectAsState()
10  val isEntitled = customerInfo?.entitlements?.get(ENTITLEMENT_PREMIUM)?.isActive == true
11
12  DetailsContent(
13    article = article,
14    onJoinClicked = navigateToPaywalls,
15    isEntitled = isEntitled,
16  )
17}

DetailsContent applies a fadingEdge modifier when isEntitled is false. Subscribed users see the article through to the end. Free users see the first few paragraphs fade into a “Join Now” CTA. The same composable runs on both iOS and Android.

Step 4: Run a purchase from commonMain

Triggering a purchase is two suspend calls: fetch the current offering, then call awaitPurchase with one of its packages. The SDK takes care of opening Google Play’s billing dialog or StoreKit’s purchase sheet, validating the receipt with the store, and posting it to RevenueCat for tracking.

The awaitPurchase helper from purchases-kmp-core accepts a Package:

1suspend fun purchaseMonthly() {
2  val offerings = Purchases.sharedInstance.awaitOfferings()
3  val current = offerings.current ?: error("No current offering configured")
4
5  val monthly = current.monthly
6    ?: current.availablePackages.first()
7
8  val transaction = Purchases.sharedInstance.awaitPurchase(monthly)
9
10  // The SDK has already verified the receipt and updated CustomerInfo.
11  // Your existing customerInfo flow will emit the new state on its own.
12}

Offering exposes convenience accessors for common cadences (monthlyannuallifetime) and a generic availablePackages: List<Package> if you want to build a custom plan picker. The cat-paywalls-kmp demo wraps this in PaywallsRepository.awaitPurchase(packageId):

1override fun awaitPurchase(packageId: String): Flow<Result<StoreTransaction>> = flow {
2  try {
3    val offerings = Purchases.sharedInstance.awaitOfferings()
4    val pkg = offerings.current?.availablePackages?.find { it.identifier == packageId }
5      ?: error("Package not found: $packageId")
6
7    val transaction = Purchases.sharedInstance.awaitPurchase(pkg)
8    emit(Result.success(transaction))
9  } catch (e: Exception) {
10    emit(Result.failure(e))
11  }
12}.flowOn(Dispatchers.IO)

Two things to know about the return value. First, the StoreTransaction is informational only. The receipt has already been validated server side by the time awaitPurchase resumes, and the user’s CustomerInfo has been updated on RevenueCat’s backend. Second, the next call to awaitCustomerInfo() will reflect the new entitlement, which means any UI bound to your customerInfo flow recomposes automatically. You do not need to manually invalidate state.

If the user cancels the dialog, the SDK throws a PurchasesException whose code is PurchaseCancelled. Catch it and treat it as a no op rather than an error.

Step 5: Drop in a server driven paywall

You could build the package picker yourself, but RevenueCat’s Paywall Editor lets you design the entire screen in the dashboard and update it without shipping a new build. The purchases-kmp-ui artifact includes a Paywall composable that renders the configured paywall on both Android and iOS.

You design the paywall in the dashboard’s visual editor:

In your KMP code, the entire paywall screen is one Paywall call. The cat-paywalls-kmp demo lives in feature/paywalls/CatCustomPaywalls.kt:

1@Composable
2fun CatCustomPaywalls() {
3  val composeNavigator = currentComposeNavigator
4
5  Box(
6    modifier = Modifier
7      .fillMaxSize()
8      .background(Color.White),
9  ) {
10    Paywall(
11      options = PaywallOptions(
12        dismissRequest = { composeNavigator.navigateUp() },
13      ),
14    )
15  }
16}

PaywallOptions is where you pass callbacks for dismissal, purchase completion, and restore. With no offering passed, the component renders the current offering assigned to the user in the dashboard. If you are running an A/B test, RevenueCat picks the variant for this user automatically and attributes any conversion to the right experiment arm.

This is the part that pays for itself. The entire visual design of the paywall, including which packages to show, how to highlight the recommended plan, what copy to use for the trial CTA, and which offering to display, is configurable from the dashboard. You can tweak headline copy or swap a one screen layout for a feature comparison layout in the morning, watch conversion metrics in the afternoon, and revert by clicking a button if the new variant underperforms. None of this requires a Play Store or App Store review cycle.

Putting it all together: the architecture

Here is how all the pieces line up in a finished KMP project. In code, the cat-paywalls-kmp demo organizes responsibilities by module:

  • composeApp is the only module with platform specific code. Its androidMain configures Purchases from the Application, its iosMain exposes a MainViewController() to SwiftUI, and its commonMain holds App.kt plus a Navigation Compose NavHost.
  • feature/* modules are screens (home, article, paywalls, account, subscriptions). They depend on core/* and contain Compose Multiplatform UI plus ViewModels.
  • core/data owns PaywallsRepository, the only place that calls into Purchases.sharedInstance. Everything else reads Flow<Offering> and Flow<CustomerInfo> from this repository.
  • core/modelcore/networkcore/designsystemcore/navigation hold the data classes, Ktor client, theme, and navigation graph respectively.

The final UX you ship looks like this. Subscribed users see the full article and a subscription management screen sourced from the same CustomerInfo object:

The same SubscriptionManagementScreen reads customerInfo.activeSubscriptionscustomerInfo.entitlements["premium"]?.isActive, and customerInfo.originalAppUserId to render its content, and runs without modification on both platforms. There is no if (Build.VERSION...) and no #if os(iOS). The code lives once, in commonMain, and the SDK does the platform translation underneath.

If you want to inspect the full source as a reference, every file shown in this article is in cat-paywalls-kmp. The minimum surface to get a working integration is the four steps above plus the dashboard setup. Everything else is product specific UI.

Conclusion

In this article, you’ve configured the RevenueCat KMP SDK in a Compose Multiplatform project, initialized Purchases on Android and iOS, gated a premium screen with CustomerInfo.entitlements, run a real purchase through awaitPurchase, and rendered a server driven paywall with the Paywall composable from purchases-kmp-ui. Every piece of subscription logic except platform initialization lives in commonMain, which means your iOS and Android apps stay in lockstep without any code duplication.

The thing worth internalizing is what the SDK takes off your plate. You no longer think in terms of purchase tokens versus signed transactions, two notification pipelines, or two receipt verification servers. You think in terms of offerings, packages, entitlements, and CustomerInfo, and the SDK collapses both stores into those four concepts. That same abstraction is what makes the visual Paywall Editor possible, since it is far easier to ship one server driven UI when there is only one shared state model underneath.

Whether you are porting an existing Android subscription app to iOS, starting a new product on KMP from scratch, or experimenting with paywall variants without burning a release cycle, this setup gives you the smallest surface area you can ship a cross platform subscription app on. The remaining engineering time goes back into the parts of your app that actually differentiate your product.

As always, happy coding!

— Jaewoong (skydoves)