The hardest part of shipping a Kotlin Multiplatform subscription app has never been Kotlin. It has been the iOS side: maintaining a Podfile alongside a Gradle build, pinning a separate PurchasesHybridCommon version next to your Kotlin SDK version, and reconciling two dependency graphs every time either side moves. RevenueCat’s Kotlin Multiplatform SDK 3.0.0 removes that second graph entirely.
The iOS integration now flows through Gradle-managed Swift package dependencies, the purchases-kmp-datetime add on is folded back into the main module, and the Android side jumps directly to purchases-android 10.x with Play Billing 8.3.0. This release also resets the minimum versions: Android 6.0 (API 23), Kotlin 2.3.20, and Compose Multiplatform 1.9.3.
In this article, you’ll explore what changed in 3.0.0 and why, the new iOS architecture built on the kn-core and kn-ui facade modules, the step by step migration from a 2.x project that still ships a Podfile, the datetime module removal and its kotlin.time.Instant replacement, the Amazon Appstore opt in change, the Android Billing 8.3.0 caveat for restored consumable purchases, and the new PostHog user ID setter.
What changed at a glance
Before 3.0.0, a Kotlin Multiplatform app using RevenueCat had two parallel native integrations. The Android target pulled in purchases-hybrid-common from Maven Central, which transitively pulled purchases-android. The iOS target pulled in PurchasesHybridCommon and optionally PurchasesHybridCommonUI through CocoaPods or Swift Package Manager, and you had to keep its version in sync with the version that your KMP release was built against. The “common files version” column in the SDK’s VERSIONS.md history is a long record of that constraint, and the version string on each 2.x release was a composite like 2.10.2+17.55.1 to make the pairing explicit.
3.0.0 collapses that pairing. The iOS target now builds directly against the native purchases-ios Swift package via a Kotlin/Native cinterop binding generated by Gradle. The Android target depends on purchases-android 10.4.0 directly, with Play Billing 8.3.0 included as a transitive dependency. There is no more “hybrid common” intermediate layer to pin, and the version table in VERSIONS.md shows Common files version: N/A for 3.0.0 for that reason.
The headline numbers:
| Surface | Before (2.10.2+17.55.1) | After (3.0.0) |
|---|---|---|
| iOS native library | PurchasesHybridCommon 17.55.1 via CocoaPods or SPM | purchases-ios 5.71.0 via Gradle (automatic) |
| iOS UI library | PurchasesHybridCommonUI 17.55.1 | bundled into kn-ui facade |
| Android library | purchases-hybrid-common (transitive) | purchases-android 10.4.0 |
| Android Billing Library | 8.0.0 | 8.3.0 |
| Android minSdk | 21 | 23 |
| Kotlin | 2.0.x | 2.3.20 |
| Compose Multiplatform | 1.7.x | 1.9.3 |
purchases-kmp-datetime module | required for Instant accessors | removed (folded into main) |
| Amazon Appstore | implicit when using the Android target | opt in via purchases-store-amazon |
The good news for application code: the public API surface of Purchases, CustomerInfo, Offerings, StoreProduct, and the rest of the model layer is unchanged. Most of the migration effort lives in your build files, your Xcode project, and a couple of import lines on the temporal accessors.
The new iOS architecture: Gradle owns the Swift dependency
The iOS rewrite is the largest change in this release, and it is the change that motivates almost every other one. Before walking through what to remove, it helps to understand what the new architecture actually does.
Two new Gradle modules ship inside the SDK: kn-core and kn-ui. The kn stands for Kotlin/Native, and these modules exist for one reason: to declare the iOS Swift dependency on purchases-ios so that Gradle can generate the Kotlin/Native cinterop bindings against it. If you examine the kn-core module’s build file:
1plugins {
2 id("revenuecat-library")
3}
4
5kotlin {
6 sourceSets {
7 iosMain.dependencies {
8 swiftPackage(
9 path = rootProject.file("upstream/purchases-ios"),
10 target = "RevenueCat",
11 packageName = "swiftPMImport.com.revenuecat.purchases.kn.core",
12 customDeclarations = """
13 // Force cinterop binding generation for types otherwise not in the public API
14 static inline int __forceBindings(
15 enum RCStoreMessageType _1
16 ) { return 0; }
17 """.trimIndent(),
18 swiftSettings = SwiftSettings {
19 define("BYPASS_SIMULATED_STORE_RELEASE_CHECK")
20 }
21 )
22
23 swiftPackage(
24 path = file("src/swift"),
25 target = "AdditionalSwift",
26 packageName = "swiftPMImport.com.revenuecat.purchases.kn.core.additional"
27 )
28 }
29 }
30}
Two things are worth pointing out.
First, the swiftPackage block is a custom Gradle DSL defined inside the SDK’s build-logic. It tells the build to compile a Swift target out of the purchases-ios checkout, then run cinterop against its generated Objective C headers, and finally expose the result under the Kotlin package swiftPMImport.com.revenuecat.purchases.kn.core.
Second, purchases-ios is consumed as a git submodule at upstream/purchases-ios. The 3.0.0 release pins that submodule to version 5.71.0. You do not need to clone it yourself when you use the published SDK artifact: the bindings are pre generated and the Swift sources are compiled when the SDK is published. From the application developer’s point of view, you simply add a Gradle dependency on com.revenuecat.purchases:purchases-kmp-core and everything else flows through Gradle.
The kn-ui module follows the same pattern for the SDK’s UI support, plus a Compose Multiplatform dependency for the Kotlin paywall composables:
1kotlin {
2 sourceSets {
3 commonMain.dependencies {
4 implementation(compose.components.resources)
5 implementation(compose.runtime)
6 }
7
8 iosMain.dependencies {
9 swiftPackage(
10 path = rootProject.file("upstream/purchases-ios"),
11 target = "RevenueCatUI",
12 packageName = "swiftPMImport.com.revenuecat.purchases.kn.ui",
13 swiftSettings = SwiftSettings {
14 define("COMPOSE_RESOURCES")
15 }
16 )
17 }
18 }
19}
The practical consequence is that your Xcode project no longer needs to know about RevenueCat at all. The framework that lands in your iOS app is the same Kotlin framework that contains your shared code, and the RevenueCat symbols are already statically linked into it through the Kotlin/Native bindings. You do not import PurchasesHybridCommon in Swift, you do not import RevenueCat in Swift, you simply call the shared Kotlin API from Swift the same way you call any other shared Kotlin symbol.
Migration step 1: Remove the iOS hybrid common dependency
The first concrete change is to remove the iOS dependencies that 3.0.0 no longer needs. The instructions differ depending on whether you added them through Swift Package Manager or CocoaPods, and the SDK ships both paths in the official migration guide.
If you used Swift Package Manager, open Xcode, select your project in the navigator, click Package Dependencies, then select PurchasesHybridCommon and PurchasesHybridCommonUI and remove them with the minus button. There is nothing else to do on the Xcode side. The Kotlin framework that your shared module produces already pulls in purchases-ios through Gradle, so removing the SPM entries does not leave your iOS target without a RevenueCat dependency.
If you used CocoaPods, the removal is in two places. The Podfile at your iOS project root might contain:
1pod 'PurchasesHybridCommon', '17.21.2'
2pod 'PurchasesHybridCommonUI', '17.21.2'
Delete those lines and run pod install once to update your Podfile.lock. Then check your Gradle build, because the KMP project might have been using the Kotlin CocoaPods plugin to declare the same dependency:
1pod("PurchasesHybridCommon") {
2 version = "17.21.2"
3 extraOpts += listOf("-compiler-option", "-fmodules")
4}
5pod("PurchasesHybridCommonUI") {
6 version = "17.21.2"
7 extraOpts += listOf("-compiler-option", "-fmodules")
8}
These blocks are the most common source of stale state when migrating, because they live in a Gradle file rather than in the iOS project itself. Search your KMP build.gradle.kts files for pod( and remove every entry that references PurchasesHybridCommon or PurchasesHybridCommonUI. If after that removal you have no pod( blocks left at all, you can remove the cocoapods plugin and the cocoapods { ... } configuration block from the KMP module entirely, which also removes the need to run pod install as part of your KMP build.
The end state on iOS in 3.0.0 is a shared module that declares its iOS targets with regular iosX64(), iosArm64(), iosSimulatorArm64() framework configurations and no CocoaPods plugin. The SDK’s own sample app does exactly that. Its iOS source set declares the framework and nothing else:
1listOf(
2 iosX64(),
3 iosArm64(),
4 iosSimulatorArm64()
5).forEach { iosTarget ->
6 iosTarget.binaries.framework {
7 baseName = "ComposeApp"
8 isStatic = true
9 }
10}
Migration step 2: Replace the datetime module with kotlin.time.Instant
The purchases-kmp-datetime module was a small companion artifact that added extension properties for converting the SDK’s millisecond timestamps into kotlinx.datetime.Instant values. Since 2.2.0+17.8.0, the recommended approach has been to use kotlin.time.Instant instead, and the previous kotlinx.datetime accessors were deprecated. In 3.0.0, the deprecated accessors are removed.
In 3.0.0, the same functionality lives in the main module and uses the kotlin.time.Instant type from the Kotlin standard library. It is annotated with @ExperimentalTime because that type itself is still experimental in the standard library.
If you upgrade through an intermediate 2.x version first, Android Studio can often auto-apply these renames via the @Deprecated(ReplaceWith = ...) hints. Otherwise, the replacement is mechanical. Find any usage of an Instant suffixed property in your code, for example firstSeenInstant, and rename it to drop the suffix: firstSeen. Then add the experimental opt in either at the file level or at the call site:
1@file:OptIn(ExperimentalTime::class)
2
3import kotlin.time.ExperimentalTime
4import kotlin.time.Instant
5
6fun describeUser(info: CustomerInfo) {
7 val seenAt: Instant = info.firstSeen
8 val latest: Instant? = info.latestExpirationDate
9 println("First seen at $seenAt, latest expiration $latest")
10}
The import line is the second thing to watch. kotlinx.datetime.Instant and kotlin.time.Instant are different types in different packages, so if you switch the property name but forget to switch the import, the compiler points you at the right fix immediately. Make sure your import line reads import kotlin.time.Instant and not the kotlinx.datetime one. After that, remove the purchases-kmp-datetime dependency from your libs.versions.toml and your build.gradle.kts files.
If you look at the source of CustomerInfo in 3.0.0, you can see how the millisecond fields and the Instant accessors now live side by side in the same class, with the latter computed lazily from the former:
1@ExperimentalTime
2public val firstSeen: Instant by lazy {
3 Instant.fromEpochMilliseconds(firstSeenMillis)
4}
5
6@ExperimentalTime
7public val latestExpirationDate: Instant? by lazy {
8 latestExpirationDateMillis?.let { Instant.fromEpochMilliseconds(it) }
9}
10
11@ExperimentalTime
12public val originalPurchaseDate: Instant? by lazy {
13 originalPurchaseDateMillis?.let { Instant.fromEpochMilliseconds(it) }
14}
firstSeenMillis, latestExpirationDateMillis, and the rest of the *Millis companions remain public and stable. If you do not want to opt into the experimental API, you can keep using the millisecond fields directly and do your own conversion. The Instant accessors are a convenience layer, not a required path.
The same pattern applies across EntitlementInfo, SubscriptionInfo, and Transaction. Every temporal accessor on these classes is annotated @ExperimentalTime, and every one returns a kotlin.time.Instant. If you previously had a serialization layer that mapped kotlinx.datetime.Instant to a wire format, you have two options. Either map from kotlin.time.Instant going forward, or stay on the *Millis accessors and serialize the long values directly. The latter is the safer choice if your serialization library does not yet have a converter for kotlin.time.Instant.
(Optional) Migration step 3: Add the Amazon Appstore module explicitly
In 2.x, the Android target of purchases-kmp always pulled the Amazon Appstore support along with it, even if your build only ever shipped to Google Play. In 3.0.0, Amazon support is opt in and lives in its own artifact, com.revenuecat.purchases:purchases-store-amazon. If your KMP app does not ship to the Amazon Appstore, you do not need to add anything. If it does, the migration is a two line addition.
In gradle/libs.versions.toml:
1purchases-amazon = { module = "com.revenuecat.purchases:purchases-store-amazon", version = "x.y.z" }
And in your Android source set of the KMP module:
1kotlin {
2 sourceSets {
3 androidMain.dependencies {
4 implementation(libs.purchases.amazon)
5 }
6 }
7}
The Amazon module is published from the purchases-android repository, not from purchases-kmp. Use the version that matches the purchases-android version pulled in by your KMP release. For 3.0.0, that is 10.4.0. RevenueCat’s installation docs list the current version next to the Maven coordinate, so check there if you are pinning explicitly.
If you forget this step on a build that previously shipped to Amazon, the symptom is a runtime check inside Purchases.configure(...) that complains the Amazon store is not registered. The compile passes because the SDK’s public API does not reference Amazon classes directly. It is worth running your Amazon build variant through a smoke test after the upgrade for this reason.
Migration step 4: Raise Android minSdk to 23 and adopt Billing 8.3.0
The Android side of 3.0.0 ships with Play Billing Library 8.3.0 through purchases-android 10.4.0. That bumps the SDK’s minimum supported Android version from API 21 to API 23 (Android 6.0). If your KMP app still advertises a minSdk of 21 or 22 in its Android source set, the merged manifest will fail to compile with an uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library [com.revenuecat.purchases:purchases-android-...] error.
The fix is to raise your minSdk to 23:
1android {
2 defaultConfig {
3 minSdk = 23
4 }
5}
The device tail below API 23 has been a fraction of a percent for several years, and most app teams have already crossed this line for unrelated reasons. If you have a hard requirement to keep API 21 support, you cannot upgrade to KMP 3.0.0 yet. Stay on the 2.x line until that requirement is removed.
There is one behavior change inside Billing 8.x that is worth flagging even though it is technically in purchases-android, not in purchases-kmp. The 3.0.0 release notes call this out explicitly:
This release updates to Billing Library 8.3.0 with min SDK supported of Android 6 (API 23), previously min was 21. It also removes a previous workaround used to be able to restore consumed one time products which is not available anymore.
The workaround in question was a path inside the older Billing Library that allowed purchases-android to surface already consumed one time products when a user called restorePurchases. Play Billing 8.x no longer exposes consumed purchases, and there is no longer a way for the SDK to reconstruct them client side. For more detail (and the recommended approach for consumables), see the RevenueCat docs.
Toolchain upgrades: Kotlin 2.3.20, Compose Multiplatform 1.9.3, Gradle 9.4.1
3.0.0 raises the floor of every part of its build toolchain. The SDK itself compiles on:
- Kotlin 2.3.20
- Compose Multiplatform 1.9.3
- Gradle 9.4.1
You do not strictly need to match every one of these in your app, but Kotlin and Compose Multiplatform are constraints because they ship metadata that the consumer side has to be compatible with. In practice, that means you cannot consume KMP 3.0.0 on a project pinned to Kotlin 2.1 or earlier, and you cannot consume the kn-ui paywall composables on a project pinned to an older Compose Multiplatform that does not yet have the matching compiler plugin.
A complete before and after of a KMP module
To pull all of this together, here is what a typical KMP module’s build file looked like in 2.x with the iOS hybrid common and datetime dependencies declared:
1plugins {
2 alias(libs.plugins.kotlin.multiplatform)
3 alias(libs.plugins.android.library)
4 alias(libs.plugins.kotlin.cocoapods)
5}
6
7kotlin {
8 androidTarget()
9 iosX64()
10 iosArm64()
11 iosSimulatorArm64()
12
13 cocoapods {
14 ios.deploymentTarget = "13.0"
15 framework { baseName = "Shared" }
16 pod("PurchasesHybridCommon") {
17 version = "17.21.2"
18 extraOpts += listOf("-compiler-option", "-fmodules")
19 }
20 pod("PurchasesHybridCommonUI") {
21 version = "17.21.2"
22 extraOpts += listOf("-compiler-option", "-fmodules")
23 }
24 }
25
26 sourceSets {
27 commonMain.dependencies {
28 implementation(libs.purchases.core)
29 implementation(libs.purchases.datetime)
30 implementation(libs.purchases.ui)
31 }
32 }
33}
The same module in 3.0.0 drops the cocoapods plugin and the pod(...) declarations, drops the purchases-kmp-datetime dependency, and adds an explicit Amazon dependency only on the Android source set if needed:
1plugins {
2 alias(libs.plugins.kotlin.multiplatform)
3 alias(libs.plugins.android.library)
4}
5
6kotlin {
7 androidTarget()
8 iosX64()
9 iosArm64()
10 iosSimulatorArm64()
11
12 sourceSets {
13 commonMain.dependencies {
14 implementation(libs.purchases.core)
15 implementation(libs.purchases.ui)
16 }
17 androidMain.dependencies {
18 implementation(libs.purchases.amazon)
19 }
20 }
21}
The Xcode side is similarly empty of RevenueCat configuration. Your Podfile no longer needs PurchasesHybridCommon or PurchasesHybridCommonUI entries, and if those were the only pods you had, you can stop running pod install entirely.
Verifying the migration
A few checks worth running after you make the changes above:
- Run a full clean build on Android and iOS. The Kotlin compiler points you at any remaining
Instantaccessor that you forgot to rename, and the build fails fast if a stalepod(...)block still references PurchasesHybridCommon. - Open the merged AndroidManifest and confirm
com.google.android.play.billingclient.versionis now set to8.3.0. - On iOS, search your Xcode project for
PurchasesHybridCommon. There should be zero hits inPackage.swift, in your project’s Package Dependencies list, and in your Podfile. The only RevenueCat symbols visible to Swift should be those re exported through your shared Kotlin framework. - Run a smoke test on a real device against a sandbox account. Configure the SDK, fetch offerings, present the paywall, and complete a purchase. The 3.0.0 release ships a Maestro E2E sample under
e2e-tests/MaestroTestAppif you want a reference for an end to end test flow. - If your app ships to the Amazon Appstore, confirm the Amazon variant still works. The runtime check inside
Purchases.configureis the canary if you forgot thepurchases-store-amazondependency.
Conclusion
In this article, you’ve explored the architectural shift that defines RevenueCat Kotlin Multiplatform SDK 3.0.0: you no longer need to worry about keeping the PurchasesHybridCommon version matched, the purchases-kmp-datetime module is folded into the main module behind the experimental kotlin.time.Instant type, the Android target ships with Play Billing 8.3.0 with a minSdk of 23, the Amazon Appstore is opt in, and the toolchain floor rises to Kotlin 2.3.20 and Compose Multiplatform 1.9.3.
The goal of purchases-kmp 3.0.0 is to make integrating the library as convenient as it should be. Please let us know what you think! All thoughts, comments and remarks are welcome.

