You've just moved your app to Kotlin Multiplatform. Your business logic is beautifully unified in commonMain. And then you remember: subscriptions.
Suddenly you're staring at a wall. StoreKit is Swift-only. BillingClient is Android-only. You can't call either from shared Kotlin code. And writing two completely separate IAP stacks — one per platform — defeats the whole point of KMP.
This is the exact problem purchases-kmp solves. It's RevenueCat's official Kotlin Multiplatform SDK that wraps both StoreKit (iOS) and Google Play Billing (Android) behind a single, unified Kotlin API you call entirely from commonMain. This guide walks through every step: installation, SDK init with platform-specific API keys, fetching offerings, making purchases, checking entitlements, displaying the Paywall composable, and integrating Customer Center.
Why KMP Changes the IAP Story
Native Android developers are used to calling BillingClient directly. Native iOS developers call StoreKit. In a KMP project, your commonMain module can't touch either — they're platform-specific APIs. Your options without RevenueCat:
- Write IAP logic in
androidMainandiosMainseparately and wire it up viaexpect/actual— effectively duplicating your business logic. - Restrict purchases to platform-specific code and pass results up to shared code — fragile and complex.
purchases-kmp eliminates both options as problems by giving you a single Purchases object available in commonMain that routes to the right native store automatically. Source
Step 1: Add the Gradle Dependency
purchases-kmp is published on Maven Central. Start by declaring the version in your version catalog:
# libs.versions.toml
[versions]
purchases-kmp = "<latest version>" # check https://github.com/RevenueCat/purchases-kmp/releases
[libraries]
purchases-core = { module = "com.revenuecat.purchases:purchases-kmp-core", version.ref = "purchases-kmp" }
purchases-ui = { module = "com.revenuecat.purchases:purchases-kmp-ui", version.ref = "purchases-kmp" }
Then add both to your shared module's commonMain source set:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.purchases.core)
implementation(libs.purchases.ui) // needed for Paywall + CustomerCenter
}
}
}
Minimum supported SDK version for Paywalls:
purchases-kmp1.5.1+13.18.1 and up. Source
Opt in to ExperimentalForeignApi
The SDK uses Kotlin/Native's C-interop bindings to call the underlying iOS framework. You must opt in to the experimental API in your iOS source sets:
// shared/build.gradle.kts (continued)
kotlin {
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
compilations["main"].compilerOptions.options.freeCompilerArgs
.add("-opt-in=kotlinx.cinterop.ExperimentalForeignApi")
}
}
Link the Native iOS Framework
The SDK depends on PurchasesHybridCommon, a native iOS framework. You must link it to your Xcode project — pure Gradle isn't enough for the iOS side.
Option A — Swift Package Manager (recommended for new projects):
- In Xcode:
File › Add Package Dependencies… - Enter
https://github.com/RevenueCat/purchases-hybrid-common - Set the Dependency Rule to Exact and use the version number that appears after the
+in yourpurchases-kmpversion string (e.g., if your version is2.1.0+14.5.0, the SPM version is14.5.0). - Select
PurchasesHybridCommon. If you're using Paywalls or Customer Center, also selectPurchasesHybridCommonUI.
Option B — CocoaPods (if your iOS project already uses it):
If Xcode is calling embedAndSignAppleFrameworkForXcode (the default from the JetBrains KMP wizard), add the pod directly to your Podfile:
# ios/Podfile
pod 'PurchasesHybridCommon', '<version after + in purchases-kmp>'
pod 'PurchasesHybridCommonUI' # if using Paywalls/CustomerCenter
Fix the Android launchMode
During a purchase, Google Play may send users to their banking app to verify payment. If your Activity's launchMode is singleTask or singleInstance, the user returning from the bank app can silently cancel the purchase. Fix this in AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:launchMode="singleTop" />
Step 2: Initialize the SDK with Platform-Specific API Keys
RevenueCat requires separate API keys for iOS (App Store) and Android (Google Play). Source You can't use a single key for both stores — so use Kotlin's expect/actual mechanism to inject the right key at runtime while keeping initialization logic in commonMain.
Define the expect declaration
// commonMain/Platform.kt
expect val revenueCatApiKey: String
Provide the actual implementations
// androidMain/Platform.android.kt
actual val revenueCatApiKey: String = "appl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // your Google Play key
// iosMain/Platform.ios.kt
actual val revenueCatApiKey: String = "appl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // your App Store key
Security note: Never hardcode production keys in source code checked into public repositories. Use build config fields (Android) or Info.plist +
xcconfig(iOS) and pull into theactualdeclarations.
Configure once at app startup
// commonMain/App.kt (called from your platform entry points)
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.PurchasesConfiguration
fun initRevenueCat(userId: String? = null) {
Purchases.logLevel = LogLevel.DEBUG // remove in production
Purchases.configure(
PurchasesConfiguration.Builder(apiKey = revenueCatApiKey)
.appUserID(userId) // null → anonymous ID generated automatically
.build()
)
}
Call this once from your Application.onCreate() on Android and from your SwiftUI App.init() or AppDelegate on iOS. Source
Step 3: Fetch Offerings and Display Products
Offerings are the server-driven product catalog you configure in the RevenueCat dashboard. Fetching them is one async call:
// commonMain/PaywallViewModel.kt
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.models.Offerings
class PaywallViewModel {
suspend fun loadOfferings(): Offerings? {
return try {
Purchases.sharedInstance.awaitOfferings()
} catch (e: Exception) {
// log and surface error to UI
null
}
}
}
From an Offering you can iterate packages — cross-platform abstractions that group equivalent Apple and Google products together:
val offering = offerings.current ?: return
for (pkg in offering.availablePackages) {
println("${pkg.packageType}: ${pkg.product.priceString}")
}
PackageType can be MONTHLY, ANNUAL, LIFETIME, etc., letting you build your UI without caring whether the underlying product came from App Store Connect or Google Play Console. Source
Step 4: Make a Purchase
// commonMain — triggered from your UI
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.models.Package
suspend fun purchasePackage(pkg: Package) {
try {
val (transaction, customerInfo) = Purchases.sharedInstance.awaitPurchase(pkg)
// transaction and customerInfo are populated on success
if (customerInfo.entitlements["pro"]?.isActive == true) {
// unlock premium features
}
} catch (e: PurchasesException) {
if (e.userCancelled) {
// user tapped "Cancel" — no need to show an error
} else {
// surface error.message to the user
}
}
}
RevenueCat automatically acknowledges and finishes the transaction on both platforms. If you have existing IAP code and need to finish transactions yourself, set purchasesAreCompletedBy in your configuration — formerly called "observer mode." Source
Step 5: Check CustomerInfo and Entitlements
Entitlement checks belong in commonMain — no platform code required:
// commonMain
import com.revenuecat.purchases.kmp.Purchases
suspend fun isProUser(): Boolean {
return try {
val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
customerInfo.entitlements["pro"]?.isActive == true
} catch (e: Exception) {
false
}
}
getCustomerInfo() is safe to call frequently; the SDK caches the latest value and only makes a network request when the cache is stale (>5 minutes). Source
You can also listen for real-time updates — useful for reacting to subscription renewals or cancellations detected while the app is open:
Purchases.sharedInstance.updatedCustomerInfoListener = UpdatedCustomerInfoListener { customerInfo ->
// update your UI state
}
Step 6: Display the RevenueCatUI Paywall Composable
Instead of building a custom paywall from scratch, use RevenueCat's remotely configurable Paywall composable — available in commonMain via purchases-kmp-ui.
// commonMain — inside your Compose Multiplatform navigation graph
import com.revenuecat.purchases.kmp.ui.revenuecatui.Paywall
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallListener
@Composable
fun PaywallScreen(onClose: () -> Unit) {
Paywall(
listener = object : PaywallListener {
override fun onPurchaseStarted(rcPackage: Package) {
// show loading indicator
}
override fun onPurchaseCompleted(customerInfo: CustomerInfo, transaction: StoreTransaction) {
onClose() // navigate away after successful purchase
}
override fun onPurchaseError(error: PurchasesError) {
// show error message
}
override fun onPurchaseCancelled() {
// user cancelled — no error needed
}
override fun onRestoreCompleted(customerInfo: CustomerInfo) {
onClose()
}
override fun onRestoreError(error: PurchasesError) { }
}
)
}
The Paywall composable is fullscreen by default. You control navigation by deciding when to render it — for example, pushing it onto your NavHost graph. Source
Real-world signal: A known open item in the
purchases-kmprepository concerns thedismissRequest/onPurchaseCompletedevent flow — specifically, ensuring the composable dismisses cleanly after a completed purchase without requiring the host to manually pop the back stack in all cases. Check the purchases-kmp releases page for the latest fixes before shipping.
Available PaywallListener callbacks: onPurchaseStarted, onPurchaseCompleted, onPurchaseError, onPurchaseCancelled, onRestoreStarted, onRestoreCompleted, onRestoreError. Source
Step 7: Customer Center Integration
Customer Center is a pre-built support UI that lets subscribers manage cancellations, request refunds, and view subscription status — reducing support load without any custom UI work.
The CustomerCenter composable lives in the same purchases-kmp-ui artifact you already added. It's as simple as:
// commonMain
import com.revenuecat.purchases.kmp.ui.revenuecatui.CustomerCenter
@Composable
fun SupportScreen(onDismiss: () -> Unit) {
CustomerCenter(
onDismiss = onDismiss
)
}
Customer Center automatically surfaces promotional offers for cancelling users — you configure those offers in App Store Connect and Google Play Console, and the UI adapts. Source
Common Pitfalls and How to Avoid Them
1. PurchasesHybridCommon version mismatch (CocoaPods vs SPM)
The purchases-kmp version string encodes the required native framework version after a + separator: e.g., 2.1.0+14.5.0 means PurchasesHybridCommon 14.5.0. If you use SPM, set the dependency rule to Exact for that version. If you use CocoaPods, specify that exact pod version. Mismatches cause cryptic linker errors at build time. Source
A common error: "Purchases / PurchasesHybridCommon required a higher minimum deployment target" — caused by a version mismatch between what your purchases-kmp version expects and what you've linked. Source
2. API Key Management
You need three API keys for a typical KMP project: - A Test Store key for development (works out of the box, KMP support from version 2.2.2+). Source - An iOS (App Store) key for production iOS builds. - An Android (Google Play) key for production Android builds.
Never ship with the Test Store key. Use build variants (Android BuildConfig, iOS xcconfig) to switch automatically. Source
3. Don't Pre-warm the Offerings Cache on Android
Calling getOfferings() inside Application.onCreate() triggers unnecessary network requests for background processes (like push notification handlers). The SDK pre-fetches offerings automatically after Purchases.configure(). Let it. Source
4. Observer Mode / purchasesAreCompletedBy
If you're migrating an existing app with its own BillingClient or StoreKit code, set purchasesAreCompletedBy to your app (formerly "observer mode") so RevenueCat doesn't double-finish transactions. Then call syncPurchases() after login to backfill history. Source
5. Static Binaries for iOS Targets
In some KMP project configurations, you must build static iOS binaries or the project won't compile. Set isStatic = true on your iOS framework binary:
// build.gradle.kts
iosX64 {
binaries.framework { isStatic = true }
}
iosArm64 {
binaries.framework { isStatic = true }
}
iosSimulatorArm64 {
binaries.framework { isStatic = true }
}
6. AndroidX App Startup
purchases-kmp uses androidx.startup under the hood. If you've blanket-removed InitializationProvider from your manifest, the SDK will fail silently. If you need to remove specific initializers (like WorkManagerInitializer), use tools:node="merge" on the provider and tools:node="remove" on the individual meta-data — don't remove the whole provider. Source
Putting It All Together
Here's a minimal end-to-end flow in shared code:
// commonMain/SubscriptionManager.kt
class SubscriptionManager {
suspend fun initialize(userId: String? = null) {
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration.Builder(revenueCatApiKey)
.appUserID(userId)
.build()
)
}
suspend fun getOffering(): Offering? =
Purchases.sharedInstance.awaitOfferings().current
suspend fun purchase(pkg: Package): CustomerInfo =
Purchases.sharedInstance.awaitPurchase(pkg).customerInfo
suspend fun isPro(): Boolean =
Purchases.sharedInstance.awaitCustomerInfo()
.entitlements["pro"]?.isActive == true
suspend fun restore(): CustomerInfo =
Purchases.sharedInstance.awaitRestorePurchases()
}
// commonMain/App.kt (Compose Multiplatform entry point)
@Composable
fun App(manager: SubscriptionManager) {
var showPaywall by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!manager.isPro()) showPaywall = true
}
if (showPaywall) {
Paywall(
listener = object : PaywallListener {
override fun onPurchaseCompleted(customerInfo: CustomerInfo, transaction: StoreTransaction) {
showPaywall = false
}
override fun onPurchaseCancelled() { showPaywall = false }
// ... other overrides
}
)
} else {
MainContent()
}
}
Zero platform-specific code. Android and iOS subscriptions from one commonMain module. ✅
Next Steps
- Configure your products and entitlements in the RevenueCat dashboard
- Design your Paywall visually with the Paywall Builder
- Test in sandbox before going live
- Read the launch checklist — especially the part about replacing Test Store keys
Sources
- Kotlin Multiplatform Installation
- Configuring the SDK
- SDK Quickstart
- Displaying Products / Offerings
- Making Purchases
- Getting Subscription Status (CustomerInfo)
- Paywalls – Installing the SDK (KMP section)
- Displaying Paywalls – KMP section
- Integrating Customer Center on Kotlin Multiplatform
- Using the SDK with your own IAP Code (Observer Mode)
- Troubleshooting the SDKs
- purchases-kmp GitHub Releases