Kotlin Multiplatform (KMM) Subscriptions: How to Implement In-App Subscriptions for Android and iOS


In this tutorial, we will walk through how to implement in-app subscriptions in a Kotlin Multiplatform Mobile (KMM) project, supporting both Android and iOS platforms using Google Play Billing 7.1.1 and StoreKit respectively.


✨ What We’ll Build

  • Unified subscription handling using KMM’s expect/actual pattern.
  • Billing implementation for Android using Google Play Billing 7.1.1.
  • Billing implementation for iOS using StoreKit and SKPaymentQueue.
  • A shared interface to check whether the user has an active subscription.

Enable Subscriptions in Google Play Store (Play Console)

To offer subscriptions in an Android app, you’ll need to configure them in Google Play Console.

✅ Step 1: Set Up a Google Play Console Account

  • Go to Google Play Console
  • Sign in with your developer account.
  • If you’re new, pay the $25 one-time registration fee and set up your developer profile.

✅ Step 2: Upload Your App (If Not Done Already)

  • Create a new app: click “Create app”.
  • Fill out required info like app name, language, and category.
  • Upload an APK or AAB (Android App Bundle).

Subscriptions require the app to be uploaded or at least have a draft before setup.

✅ Step 3: Go to “Monetize > Products > Subscriptions”

  • In the left-hand menu, navigate to:
    MonetizeProductsSubscriptions
  • Click “Create subscription”

✅ Step 4: Define Your Subscription

You’ll need to fill in:

  • Product ID (unique identifier, like premium_monthly)
  • Subscription name & description
  • Base plan(s): Define billing period (weekly, monthly, yearly), price, free trial, etc.
  • Optionally add offers (intro pricing, custom plans, etc.)

Enable Subscriptions in Apple App Store (App Store Connect)

To offer subscriptions on iOS, you’ll use App Store Connect.

✅ Step 1: Enroll in the Apple Developer Program

✅ Step 2: Create Your App in App Store Connect

  • Log in at App Store Connect
  • Click My Apps+ to create a new app
  • Fill out your app info (name, bundle ID, etc.)

✅ Step 3: Set Up In-App Purchases

  • Go to your app > Monetization > Subscriptions
  • Click “+” and start adding subscription group

✅ Step 4: Define Your Subscription Group

Subscriptions need to be part of a subscription group:

  • Add a new group (e.g. “Pro Plans”)
  • Inside the group, add your subscription(s) — like monthly, yearly, etc.

✅ Step 5: Configure Subscription Details

For each subscription:

  • Reference name (internal)
  • Product ID (unique, like pro_monthly)
  • Duration (weekly, monthly, yearly)
  • Price (based on App Store pricing tiers)
  • Optional: Free trial or intro offer

⚠️ Apple requires all subscriptions to have a localized display name & description.


    Define Shared Expect Class (commonMain)

    expect class SubscriptionManager() {
        fun purchaseSubscription(callback: (Boolean) -> Unit)
        fun isUserSubscribed(callback: (Boolean) -> Unit)
        fun manageSubscription()
    }

    Android Implementation (androidMain)

    Dependencies (build.gradle.kts)
    dependencies { implementation("com.android.billingclient:billing-ktx:7.1.1")} 

    BillingHelper for Android

    Kotlin helper class to handle android native subscrptions mechanism using android.billingclient api

    BillingHelper.kt

    package iap
    
    import android.app.Activity
    import android.content.Context
    import android.util.Log
    import com.android.billingclient.api.AcknowledgePurchaseParams
    import com.android.billingclient.api.BillingClient
    import com.android.billingclient.api.BillingClientStateListener
    import com.android.billingclient.api.BillingFlowParams
    import com.android.billingclient.api.BillingResult
    import com.android.billingclient.api.PendingPurchasesParams
    import com.android.billingclient.api.Purchase
    import com.android.billingclient.api.QueryProductDetailsParams
    import com.android.billingclient.api.QueryPurchasesParams
    
    class BillingHelper(private val context: Context) {
    
        val BIGG_BOSS_PREMIUM ="premium_subscription"
        val TAG = BillingHelper::class.simpleName
        var purchaseCallback:((isSuccess:Boolean)->Unit)? = null
    
        var params: PendingPurchasesParams = PendingPurchasesParams.newBuilder()
            .enableOneTimeProducts()
            .enablePrepaidPlans()
            .build()
    
        private var billingClient: BillingClient
    
        init {
            billingClient = BillingClient.newBuilder(context)
                .enablePendingPurchases(params)
                .setListener { billingResult, purchases ->
                    Log.d("enablePendingPurchases", "BillingClient creation result : ${billingResult.responseCode}")
                    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
                        handlePurchase(purchases)
                    }
                }.build()
        }
    
        private fun connectPlayStore(callback: (isConnected:Boolean) -> Unit) {
            if (billingClient.isReady) {
                callback.invoke(true)
            } else {
                billingClient.startConnection(object : BillingClientStateListener {
                    override fun onBillingSetupFinished(billingResult: BillingResult) {
                        Log.d(TAG, "startConnection onBillingSetupFinished")
                        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                            // BillingClient is ready
                            Log.d(TAG, "BillingClient is ready")
                            callback.invoke(true)
                        }
                    }
    
                    override fun onBillingServiceDisconnected() {
                        Log.d(TAG, "startConnection onBillingServiceDisconnected")
                    }
                })
            }
        }
    
        fun checkSubscriptionStatus(callback: (Boolean) -> Unit) {
            Log.d(TAG, "checkSubscriptionStatus")
            connectPlayStore {
                val params = QueryPurchasesParams.newBuilder()
                    .setProductType(BillingClient.ProductType.SUBS)
                    .build()
    
                billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
                    Log.d(TAG, "queryPurchasesAsync callback: ${billingResult.responseCode}, ${purchases.size}")
                    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                        val isSubscribed = purchases.any { it.products.contains(BIGG_BOSS_PREMIUM) }
                        callback(isSubscribed)
                    } else {
                        callback(false)
                    }
                }
            }
    
        }
    
        fun purchaseSubscription(activity: Activity, callback: (Boolean) -> Unit) {
            val params = QueryProductDetailsParams.newBuilder()
                .setProductList(
                    listOf(
                        QueryProductDetailsParams.Product.newBuilder()
                            .setProductId(BIGG_BOSS_PREMIUM)
                            .setProductType(BillingClient.ProductType.SUBS)
                            .build()
                    )
                )
                .build()
    
            billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetailsList.isNotEmpty()) {
                    val productDetails = productDetailsList.first()
                    val offerToken = productDetails.subscriptionOfferDetails?.first()?.offerToken
                        ?: return@queryProductDetailsAsync
    
                    val billingParams = BillingFlowParams.newBuilder()
                        .setProductDetailsParamsList(
                            listOf(
                                BillingFlowParams.ProductDetailsParams.newBuilder()
                                    .setProductDetails(productDetails)
                                    .setOfferToken(offerToken)
                                    .build()
                            )
                        )
                        .build()
    
                    purchaseCallback = callback
                    billingClient.launchBillingFlow(activity, billingParams)
                }
            }
        }
    
        private fun handlePurchase(purchases: List<Purchase>) {
            for (purchase in purchases) {
                if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                    // Grant subscription benefits
                    Log.d("BillingManager", "Subscription is active: ${purchase.products}")
                    acknowledgePurchase(purchase)
                    purchaseCallback?.invoke(true)
                    purchaseCallback = null
                }
            }
        }
    
        private fun acknowledgePurchase(purchase: Purchase) {
            if (!purchase.isAcknowledged) {
                val params = AcknowledgePurchaseParams.newBuilder()
                    .setPurchaseToken(purchase.purchaseToken)
                    .build()
    
                billingClient.acknowledgePurchase(params) { billingResult ->
                    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                        Log.d("BillingManager", "Purchase acknowledged")
                    }
                }
            }
        }
    
    }

    Integrate helper class with SubscriptionManager for Android (shared)

    SubscriptionManager.android.kt

    package iap
    
    import android.app.Activity
    import ui.native.LinkLauncher
    import ui.native.PiShared
    
    actual class SubscriptionManager {
    
        private val billingHelper = BillingHelper(PiShared.applicationContext!!)
        actual fun purchaseSubscription(callback: (Boolean) -> Unit) {
            (PiShared.applicationContext as? Activity)?.let {
                billingHelper.purchaseSubscription(it, callback)
            }
        }
        actual fun isUserSubscribed(callback: (Boolean) -> Unit) {
            billingHelper.checkSubscriptionStatus (callback)
        }
    
        actual fun manageSubscription() {
            LinkLauncher().openLink("https://play.google.com/store/account/subscriptions")
        }
    
    }

    3. iOS Implementation (iOSApp)

    Swift code that handle iOS native implementation for Subscription handling. Add SubscriptionManager.swift in the following folder

    iosApp/iOSApp/Subscription

    //
    //  SubscriptionManager.swift
    //  iosApp
    //
    //  Created by Apple on 4/2/25.
    //  Copyright © 2025 orgName. All rights reserved.
    //
    
    import Foundation
    import StoreKit
    
    @objc public class SubscriptionManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
        let PRODUCT_ID = "ios_subscription"
        @objc public static let shared = SubscriptionManager()
        var products: [SKProduct] = []
        
        var purchaseCallBack:((Bool)->Void)? = nil
        
        override init() {
            super.init()
            SKPaymentQueue.default().add(self)
        }
        
        @objc public func fetchSubscriptions() {
            let request = SKProductsRequest(productIdentifiers: [PRODUCT_ID])
            request.delegate = self
            request.start()
        }
        
        public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
            self.products = response.products
            print("productsRequest is called: \(self.products)")
            for product in products {
                print(product.productIdentifier)
            }
        }
        
        @objc public func purchaseSubscription(productId: String, callback:(@escaping (Bool)-> Void)) {
            
            guard let product = products.first(where: { $0.productIdentifier == productId }) else { return }
            let payment = SKPayment(product: product)
            purchaseCallBack = callback
            SKPaymentQueue.default().add(payment)
        }
        
        @objc public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
            for transaction in transactions {
                switch transaction.transactionState {
                case .purchased:
                    unlockContent(productId: transaction.payment.productIdentifier)
                    
                    SKPaymentQueue.default().finishTransaction(transaction)
                case .failed:
                    SKPaymentQueue.default().finishTransaction(transaction)
                default:
                    break
                }
            }
        }
        
        private func unlockContent(productId: String) {
            purchaseCallBack?(true)
            UserDefaults.standard.set(true, forKey: productId)
        }
        
        @objc public func isSubscribed(productId: String) -> Bool {
            return UserDefaults.standard.bool(forKey: productId)
        }
    }

    Expose swift code to Kotlin using swift-klib

    Swift-Klib gradle plugin provides easy way to include your Swift source files in your Kotlin Multiplatform Mobile shared module and access them in Kotlin via cinterop for iOS targets.  https://github.com/ttypic/swift-klib-plugin

    Update libs.version.toml

    [version]
    swift-klib = "0.6.4"
    [plugins]
    swift-klib = { id = "io.github.ttypic.swiftklib", version.ref = "swift-klib"}

    Update KMM “shared/build.gradle.kts”

    plugins {
        alias(libs.plugins.swift.klib)
    
    }
    
    kotlin {
    
      listOf(
            iosX64(),
            iosArm64(),
            iosSimulatorArm64()
        ).forEach { iosTarget ->
            iosTarget.compilations {
                val main by getting {
                    cinterops {
                        create("Subscription")
                    }
                }
            }
        }
    }
    
    // ../iosApp/iosApp/Subscription is folder path where Subscription.swift
    
    swiftklib {
        create("Subscription") {
            path = file("../iosApp/iosApp/Subscription")
             // Kotlin package name
            packageName("com.piappstudio.swift.subscription")
        }
    }

    iOS Implementation (shared/iOSMain)

    Let’s access Swift function from Kotlin 

    SubscriptionManager.ios.kt

    package iap
    import co.touchlab.kermit.Logger
    import com.piappstudio.swift.subscription.SubscriptionManager
    import kotlinx.cinterop.ExperimentalForeignApi
    import model.PiGlobalInfo
    import ui.native.LinkLauncher
    
    @OptIn(ExperimentalForeignApi::class)
    actual class SubscriptionManager  {
    
        val productId = "tamil"
         var subscriptionManager: SubscriptionManager = SubscriptionManager.shared()
    
        init {
            subscriptionManager.fetchSubscriptions()
        }
    
        actual fun purchaseSubscription(callback: (Boolean) -> Unit) {
            Logger.d(tag = "SubscriptionManager", messageString = "purchaseSubscription")
            subscriptionManager.purchaseSubscriptionWithProductId(productId, callback = callback)
        }
    
        @OptIn(ExperimentalForeignApi::class)
        actual fun isUserSubscribed(callback: (Boolean) -> Unit) {
            Logger.d("isUserSubscribed")
            val isSubscribed = subscriptionManager.isSubscribedWithProductId(productId)
            Logger.d(tag = "SubscriptionManager",  messageString = "isSubscribed: $isSubscribed")
            callback.invoke(isSubscribed)
        }
    
        actual fun manageSubscription() {
            LinkLauncher().openLink("https://apps.apple.com/account/subscriptions")
        }
    
    }

    4. Usage in Shared Code

    private fun checkSubscription() {
        screenModelScope.launch (Dispatchers.IO)  {
            subscriptionManager.isUserSubscribed { isPremiumUser ->
                Logger.d("Subscription Manager: $isPremiumUser")
                val premiumStatus = if (isPremiumUser) PremiumStatus.Subscribed else PremiumStatus.UnSubscribed
                piGlobalUtil.updateAppState(piGlobalUtil.appState.value.copy(premiumStatus = premiumStatus))
            }
        }
    }
    
    // To add button for subscription
    Button(onClick = {
                    subscriptionManager.purchaseSubscription(callback)
                },
                    modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors().copy(containerColor = PiColor.goldStar,
                        contentColor = MaterialTheme.colorScheme.onSurface)
                ) {
                    Text(
                        "GO PREMIUM FOR $0.99 / YEAR",
                        fontWeight = FontWeight.ExtraBold,
                        modifier = Modifier.padding(Dimens.space)
                    )
                })
                
    // To display "Manage Subscription " option
    Button(onClick = {
                    subscriptionManager.manageSubscription()
                },
                    modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors().copy(containerColor = PiColor.goldStar,
                        contentColor = MaterialTheme.colorScheme.onSurface)
                ) {
                    Text(
                        "Manage Subscription",
                        fontWeight = FontWeight.ExtraBold,
                        modifier = Modifier.padding(Dimens.space)
                    )
                })
                
                

    🚀 Summary

    Now you have:

    • A complete subscription flow for both Android and iOS in KMM.
    • Observer integration for iOS via StoreObserver.
    • Google Play Billing integration via version 7.1.1.

    Want to add restore purchase support or server-side receipt validation? Drop a comment or request a follow-up!

    Happy coding ✨

    1 thought on “Kotlin Multiplatform (KMM) Subscriptions: How to Implement In-App Subscriptions for Android and iOS”

    1. Pingback: Kotlin Multiplatform (KMM) Subscriptions: How to Implement In-App Subscriptions for Android and iOS | by Boobalan Munusamy | Apr, 2025 - Techcaro.com

    Leave a Comment

    Your email address will not be published. Required fields are marked *