---
title: "Announcing RevenueCat Kotlin Multiplatform SDK 3.0.0: a cleaner iOS setup"
description: "In this article, you'll explore what changed in 3.0.0 and why, the new iOS architecture, and step-by-step migration guides."
language: "en"
publishedAt: "2026-05-14T21:31:36Z"
updatedAt: "2026-05-14T21:31: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/kmp-sdk-3"
---

# Announcing RevenueCat Kotlin Multiplatform SDK 3.0.0: a cleaner iOS setup

In this article, you'll explore what changed in 3.0.0 and why, the new iOS architecture, and step-by-step migration guides.

## Table of contents

- [What changed at a glance](#what-changed-at-a-glance)
- [The new iOS architecture: Gradle owns the Swift dependency](#the-new-ios-architecture-gradle-owns-the-swift-dependency)
- [Migration step 1: Remove the iOS hybrid common dependency](#migration-step-1-remove-the-ios-hybrid-common-dependency)
- [Migration step 2: Replace the datetime module with kotlin.time.Instant](#migration-step-2-replace-the-datetime-module-with-kotlintimeinstant)
- [(Optional) Migration step 3: Add the Amazon Appstore module explicitly](#optional-migration-step-3-add-the-amazon-appstore-module-explicitly)
- [Migration step 4: Raise Android minSdk to 23 and adopt Billing 8.3.0](#migration-step-4-raise-android-minsdk-to-23-and-adopt-billing-830)
- [Toolchain upgrades: Kotlin 2.3.20, Compose Multiplatform 1.9.3, Gradle 9.4.1](#toolchain-upgrades-kotlin-2320-compose-multiplatform-193-gradle-941)
- [A complete before and after of a KMP module](#a-complete-before-and-after-of-a-kmp-module)
- [Verifying the migration](#verifying-the-migration)
- [Conclusion](#conclusion)

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:

```kotlin
plugins {
    id("revenuecat-library")
}

kotlin {
    sourceSets {
        iosMain.dependencies {
            swiftPackage(
                path = rootProject.file("upstream\/purchases-ios"),
                target = "RevenueCat",
                packageName = "swiftPMImport.com.revenuecat.purchases.kn.core",
                customDeclarations = """
                    \/\/ Force cinterop binding generation for types otherwise not in the public API
                    static inline int __forceBindings(
                        enum RCStoreMessageType _1
                    ) { return 0; }
                """.trimIndent(),
                swiftSettings = SwiftSettings {
                    define("BYPASS_SIMULATED_STORE_RELEASE_CHECK")
                }
            )

            swiftPackage(
                path = file("src\/swift"),
                target = "AdditionalSwift",
                packageName = "swiftPMImport.com.revenuecat.purchases.kn.core.additional"
            )
        }
    }
}
```

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:

```kotlin
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(compose.components.resources)
            implementation(compose.runtime)
        }

        iosMain.dependencies {
            swiftPackage(
                path = rootProject.file("upstream\/purchases-ios"),
                target = "RevenueCatUI",
                packageName = "swiftPMImport.com.revenuecat.purchases.kn.ui",
                swiftSettings = SwiftSettings {
                    define("COMPOSE_RESOURCES")
                }
            )
        }
    }
}
```

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:

```kotlin
pod 'PurchasesHybridCommon', '17.21.2'
pod '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:

```kotlin
pod("PurchasesHybridCommon") {
    version = "17.21.2"
    extraOpts += listOf("-compiler-option", "-fmodules")
}
pod("PurchasesHybridCommonUI") {
    version = "17.21.2"
    extraOpts += listOf("-compiler-option", "-fmodules")
}
```

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:

```kotlin
listOf(
    iosX64(),
    iosArm64(),
    iosSimulatorArm64()
).forEach { iosTarget ->
    iosTarget.binaries.framework {
        baseName = "ComposeApp"
        isStatic = true
    }
}
```

## **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:

```kotlin
@file:OptIn(ExperimentalTime::class)

import kotlin.time.ExperimentalTime
import kotlin.time.Instant

fun describeUser(info: CustomerInfo) {
    val seenAt: Instant = info.firstSeen
    val latest: Instant? = info.latestExpirationDate
    println("First seen at $seenAt, latest expiration $latest")
}
```

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:

```kotlin
@ExperimentalTime
public val firstSeen: Instant by lazy {
    Instant.fromEpochMilliseconds(firstSeenMillis)
}

@ExperimentalTime
public val latestExpirationDate: Instant? by lazy {
    latestExpirationDateMillis?.let { Instant.fromEpochMilliseconds(it) }
}

@ExperimentalTime
public val originalPurchaseDate: Instant? by lazy {
    originalPurchaseDateMillis?.let { Instant.fromEpochMilliseconds(it) }
}
```

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

```kotlin
purchases-amazon = { module = "com.revenuecat.purchases:purchases-store-amazon", version = "x.y.z" }
```

And in your Android source set of the KMP module:

```kotlin
kotlin {
    sourceSets {
        androidMain.dependencies {
            implementation(libs.purchases.amazon)
        }
    }
}
```

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:

```kotlin
android {
    defaultConfig {
        minSdk = 23
    }
}
```

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](https://www.revenuecat.com/docs/known-store-issues/play-billing-library/restore-consumable-purchases-bc8#restoring-purchases-by-order-id).

## **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:

```kotlin
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.cocoapods)
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    cocoapods {
        ios.deploymentTarget = "13.0"
        framework { baseName = "Shared" }
        pod("PurchasesHybridCommon") {
            version = "17.21.2"
            extraOpts += listOf("-compiler-option", "-fmodules")
        }
        pod("PurchasesHybridCommonUI") {
            version = "17.21.2"
            extraOpts += listOf("-compiler-option", "-fmodules")
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.purchases.core)
            implementation(libs.purchases.datetime)
            implementation(libs.purchases.ui)
        }
    }
}
```

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:

```kotlin
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            implementation(libs.purchases.core)
            implementation(libs.purchases.ui)
        }
        androidMain.dependencies {
            implementation(libs.purchases.amazon)
        }
    }
}
```

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:

1. Run a full clean build on Android and iOS. The Kotlin compiler points you at any remaining `Instant` accessor that you forgot to rename, and the build fails fast if a stale `pod(...)` block still references PurchasesHybridCommon.
1. Open the merged AndroidManifest and confirm `com.google.android.play.billingclient.version` is now set to `8.3.0`.
1. On iOS, search your Xcode project for `PurchasesHybridCommon`. There should be zero hits in `Package.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.
1. 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/MaestroTestApp` if you want a reference for an end to end test flow.
1. If your app ships to the Amazon Appstore, confirm the Amazon variant still works. The runtime check inside `Purchases.configure` is the canary if you forgot the `purchases-store-amazon` dependency.
## **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.

---

## Related posts

- [Compose Multiplatform subscriptions: single codebase for iOS and Android](https://www.revenuecat.com/blog/engineering/cmp-subscriptions)
- [Testing subscriptions on Compose Multiplatform: one test suite for iOS and Android](https://www.revenuecat.com/blog/engineering/testing-subscription-cmp)
- [How we built the RevenueCat SDK for Kotlin Multiplatform](https://www.revenuecat.com/blog/engineering/how-we-built-the-revenuecat-sdk-for-kotlin-multiplatform)
