MVP Factory
ai startup development

KMP subscription architecture: one engine, two platforms

KW
Krystian Wiewiór · · 5 min read

Meta description: Build a shared KMP subscription engine with expect/actual interfaces, server-side receipt validation, and a single entitlement state machine for iOS and Android.


TL;DR

Duplicating subscription logic across Swift and Kotlin is a maintenance liability. With Kotlin Multiplatform’s expect/actual pattern, you can abstract RevenueCat, StoreKit 2, and Google Play Billing behind a single interface, then run one state machine that handles entitlements, grace periods, and billing retries for both platforms from shared code.


What most teams get wrong about paywall architecture

Most teams wire up RevenueCat on iOS, wire it up again on Android, then write two versions of the same grace-period logic, two cache invalidation strategies, and two entitlement checks. In my experience building production subscription systems, this duplication compounds silently — until a billing edge case surfaces on one platform that was already fixed on the other six months earlier.

A typical subscription flow touches six discrete concerns: purchase initiation, receipt validation, entitlement resolution, grace period handling, billing retry, and cache invalidation. Six concerns × two platforms = twelve code paths to maintain. KMP collapses that to a single shared module with thin platform adapters.


The abstraction layer: expect/actual for purchase providers

The core interface lives in commonMain:

// commonMain
interface PurchaseProvider {
    suspend fun purchase(productId: String): PurchaseResult
    suspend fun restorePurchases(): List<PurchaseResult>
    suspend fun getOfferings(): List<Offering>
}

expect fun createPurchaseProvider(): PurchaseProvider

Each platform delivers its own actual implementation:

// iosMain
actual fun createPurchaseProvider(): PurchaseProvider = RevenueCatIOSProvider()

// androidMain
actual fun createPurchaseProvider(): PurchaseProvider = RevenueCatAndroidProvider()

RevenueCat’s SDK already abstracts most platform differences, but the expect/actual boundary still matters — it keeps your shared business logic decoupled from any specific vendor, including RevenueCat itself.


Server-side receipt validation: two APIs, one shared contract

Platform validation APIs are fundamentally different. iOS uses the App Store Server API with JWT-signed JWS transactions. Android uses Google Real-Time Developer Notifications (RTDN) via Pub/Sub, with server-to-server token verification through the Google Play Developer API.

ConcerniOS (App Store Server API)Android (Google Play Billing)
Validation methodJWS transaction (JWT)Purchase token via REST
Real-time eventsApp Store Server NotificationsRTDN via Cloud Pub/Sub
Grace period signalexpirationIntent fieldpaymentState = 0
Billing retry windowUp to 60 daysUp to 30 days
Sandbox vs prodEnvironment header in JWTSeparate test accounts

Your backend should expose a single /validate-receipt endpoint that accepts a platform-agnostic payload and routes internally. The response contract is what your shared KMP module cares about, not the underlying platform mechanics.

// commonMain
data class EntitlementState(
    val isActive: Boolean,
    val expiresAt: Instant,
    val inGracePeriod: Boolean,
    val billingRetryActive: Boolean,
    val billingRetryUntil: Instant?
)

The state machine: one source of truth for entitlements

This is where most teams lose real money. Grace periods and billing retries aren’t just UI states — they gate feature access and determine when to show recovery paywalls.

sealed class SubscriptionState {
    object Active : SubscriptionState()
    data class GracePeriod(val expiresAt: Instant) : SubscriptionState()
    data class BillingRetry(val retryUntil: Instant) : SubscriptionState()
    object Expired : SubscriptionState()
    object NeverSubscribed : SubscriptionState()
}

class SubscriptionStateMachine(
    private val provider: PurchaseProvider,
    private val cache: EntitlementCache
) {
    fun resolve(entitlement: EntitlementState): SubscriptionState = when {
        entitlement.isActive -> SubscriptionState.Active
        entitlement.inGracePeriod -> SubscriptionState.GracePeriod(entitlement.expiresAt)
        entitlement.billingRetryActive && entitlement.billingRetryUntil != null ->
            SubscriptionState.BillingRetry(entitlement.billingRetryUntil)
        entitlement.expiresAt < Clock.System.now() -> SubscriptionState.Expired
        else -> SubscriptionState.NeverSubscribed
    }

    suspend fun refresh(): EntitlementState {
        val fresh = provider.restorePurchases()
        val state = resolveFromResults(fresh)
        cache.write(state)
        return state
    }
}

This state machine runs entirely in commonMain. Your SwiftUI views and Compose screens observe the same SubscriptionState flow — no platform-specific branching required.


Entitlement caching without race conditions

Cache on device, validate server-side asynchronously, and never block the UI on cold start. A two-layer strategy works well: a local EncryptedSharedPreferences/Keychain cache for instant reads, with a background refresh triggered on app foreground. Invalidate eagerly on any purchase event, and always re-validate before showing premium content if the cached state is older than a configurable TTL.

Five minutes is a reasonable default. Short enough that a subscriber who cancels won’t keep seeing premium content, but long enough to avoid hammering your validation endpoint on typical mobile sessions. Make it configurable so you can tighten it for high-value paywalls without a release.


Takeaways

  1. Define your purchase interface in commonMain first. Resist the temptation to start with RevenueCat’s platform SDK and work upward. The abstraction boundary protects you from vendor changes and keeps your business logic testable without a device.

  2. Centralize receipt validation on your backend behind a single contract. Platform-specific JWT and RTDN handling belongs in your server layer, not your mobile client. Return a normalized EntitlementState — including a dedicated billingRetryUntil field — that your KMP module can consume without knowing which platform produced it.

  3. Model subscription lifecycle as an explicit state machine in shared code. Grace periods, billing retries, and expiry aren’t special cases — they’re first-class states. Write this logic once in Kotlin and expose it to both Swift and Android. That eliminates the entire class of bugs where one platform handles a billing edge case correctly and the other doesn’t.


Tags: kotlin kmp multiplatform mobile architecture


Share: Twitter LinkedIn