Purchases and Subscription Status

Own your purchase logic with a PurchaseController.

Beta

The KMP SDK is in beta and its API may change between releases.

By default Superwall handles purchases and subscription status for you, and most apps should leave it that way. If you already have purchase logic (your own billing stack, or a provider like RevenueCat), you can take it over with a PurchaseController.

Passing a PurchaseController means you own subscription status. Superwall will not set it for you, so you must set Superwall.subscriptionStatus yourself after every purchase, restore, and app launch.

The interface

One interface covers both stores. Each platform invokes only its own store's method, so you implement all three and only two ever run on a given device.

import com.superwall.sdk.kmp.PurchaseController
import com.superwall.sdk.kmp.models.results.PurchaseResult
import com.superwall.sdk.kmp.models.results.RestorationResult

class MyPurchaseController : PurchaseController {

    override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
        // Your StoreKit logic
        return PurchaseResult.Purchased
    }

    override suspend fun purchaseFromGooglePlay(
        productId: String,
        basePlanId: String?,
        offerId: String?,
    ): PurchaseResult {
        // Your Play Billing logic
        return PurchaseResult.Purchased
    }

    override suspend fun restorePurchases(): RestorationResult {
        // Your restore logic
        return RestorationResult.Restored
    }
}

All three are suspend functions, so you can do the real asynchronous work inline without callbacks.

purchaseFromGooglePlay takes basePlanId and offerId alongside the product id, because Play models subscriptions with base plans and offers. iOS has no equivalent, which is why the two entry points are separate rather than one method with platform-shaped arguments.

Handling every case

PurchaseResult is a sealed interface, so handle all four:

ResultWhen
PurchasedThe product was purchased
CancelledThe user cancelled. StoreKit 2's .userCancelled, or RevenueCat's userCancelled == true
PendingAwaiting action. StoreKit 1's .deferred, or RevenueCat's paymentPendingError
Failed(error)Anything else
override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
    return try {
        when (val outcome = myStore.purchase(productId)) {
            is Success -> PurchaseResult.Purchased
            is UserCancelled -> PurchaseResult.Cancelled
            is Deferred -> PurchaseResult.Pending
            is Error -> PurchaseResult.Failed(outcome.message)
        }
    } catch (e: Exception) {
        PurchaseResult.Failed(e.message ?: "Unknown error")
    }
}

There are convenience factories if you prefer them: PurchaseResult.purchased(), .cancelled(), .pending(), .failed(error).

RestorationResult has two cases, Restored and Failed(error).

RestorationResult.Restored means the restore completed without errors, not that the user has an active subscription. Set subscription status from the entitlements you actually resolved, not from the fact that restore succeeded.

Wire it up

Pass the controller at configure time:

Superwall.configure(
    apiKey = "pk_your_api_key",
    purchaseController = MyPurchaseController(),
)

A second configure call will not install a different purchase controller, because repeat calls are a no-op. Set it on the first call.

Keep subscription status current

This is the part that is easy to forget. After any purchase, restore, or launch-time entitlement check, tell Superwall what you found:

import com.superwall.sdk.kmp.models.entitlements.Entitlement
import com.superwall.sdk.kmp.models.entitlements.SubscriptionStatus

fun syncSubscriptionStatus(activeEntitlementIds: Set<String>) {
    Superwall.subscriptionStatus = if (activeEntitlementIds.isEmpty()) {
        SubscriptionStatus.Inactive
    } else {
        SubscriptionStatus.Active(
            activeEntitlementIds.map { Entitlement(id = it) }.toSet(),
        )
    }
}

Entitlement requires only an id; the remaining fields have defaults.

Do not leave the status at SubscriptionStatus.Unknown once you know the answer. Gated paywalls and your own UI both branch on it.

Consumables on Android

Play Billing requires consuming a purchase before the same product can be bought again:

val token = Superwall.consume(purchaseToken)

This is an Android operation. On iOS it echoes the token back unchanged, so it is safe to call from shared code without a platform check.

Restoring

Superwall.restorePurchases() routes through your controller's restorePurchases() when one is configured, and through the native SDK otherwise:

when (val result = Superwall.restorePurchases()) {
    is RestorationResult.Restored -> println("Restored")
    is RestorationResult.Failed -> println("Failed: ${result.error}")
}

Failure stays in the return type and does not throw.

Observing purchases you did not make

If you want Superwall to see transactions that happen outside of a paywall without taking over purchasing entirely, skip the controller and set an option instead:

Superwall.configure(
    apiKey = "pk_your_api_key",
    options = SuperwallOptions(shouldObservePurchases = true),
)

How is this guide?

On this page