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:
| Result | When |
|---|---|
Purchased | The product was purchased |
Cancelled | The user cancelled. StoreKit 2's .userCancelled, or RevenueCat's userCancelled == true |
Pending | Awaiting 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?