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:

  1. Write IAP logic in androidMain and iosMain separately and wire it up via expect/actual — effectively duplicating your business logic.
  2. 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-kmp 1.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")
    }
}

Source

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):

  1. In Xcode: File › Add Package Dependencies…
  2. Enter https://github.com/RevenueCat/purchases-hybrid-common
  3. Set the Dependency Rule to Exact and use the version number that appears after the + in your purchases-kmp version string (e.g., if your version is 2.1.0+14.5.0, the SPM version is 14.5.0).
  4. Select PurchasesHybridCommon. If you're using Paywalls or Customer Center, also select PurchasesHybridCommonUI.

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

Source

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" />

Source


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 the actual declarations.

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-kmp repository concerns the dismissRequest / onPurchaseCompleted event 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
    )
}

Source

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 }
}

Source

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


Sources