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.

Youtube Video

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 *