Building Subscription-Based Mobile Apps: Architecture Guide

Subscription revenue is the dominant business model for mobile apps in 2022. From fitness and meditation apps to productivity tools and content platforms, recurring revenue provides predictable income and higher lifetime customer value than one-time purchases.

But building a reliable subscription system is significantly more complex than adding a “buy” button. You need to handle purchase flows, receipt validation, subscription lifecycle events, grace periods, platform differences, and edge cases that only appear at scale. This guide covers the architecture required to get it right.

Subscription Architecture Overview

Subscription Architecture Overview Infographic

A robust subscription system has four layers:

  1. Presentation: Purchase UI, paywall design, subscription management screens
  2. Store Integration: Platform-specific purchase APIs (StoreKit 2, Google Play Billing)
  3. Validation: Server-side receipt verification
  4. Entitlement: Determining what features the user can access based on their subscription state

The Critical Rule

Never trust the client. Always validate purchases server-side. A user with a jailbroken device or a modified APK can fake client-side purchase confirmations. Your server must independently verify every transaction with Apple or Google.

iOS

iOS: StoreKit 2 Infographic : StoreKit 2

StoreKit 2, introduced in iOS 15, dramatically simplifies subscription management compared to the original StoreKit. It uses modern Swift concurrency and provides a cleaner API.

Product Configuration

Define your subscription products in App Store Connect, then load them in your app:

import StoreKit

class SubscriptionManager: ObservableObject {
    @Published var products: [Product] = []
    @Published var purchasedSubscriptions: [Product] = []
    @Published var subscriptionStatus: SubscriptionStatus = .none

    private let productIds = [
        "com.example.app.monthly",
        "com.example.app.annual",
    ]

    func loadProducts() async {
        do {
            products = try await Product.products(for: productIds)
                .sorted { $0.price < $1.price }
        } catch {
            print("Failed to load products: \(error)")
        }
    }
}

Purchase Flow

extension SubscriptionManager {
    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)

            // Send to your server for validation
            await validateOnServer(transaction)

            // Finish the transaction
            await transaction.finish()

            // Update local state
            await updateSubscriptionStatus()

            return transaction

        case .userCancelled:
            return nil

        case .pending:
            // Transaction requires approval (Ask to Buy)
            return nil

        @unknown default:
            return nil
        }
    }

    private func checkVerified<T>(
        _ result: VerificationResult<T>
    ) throws -> T {
        switch result {
        case .unverified:
            throw SubscriptionError.verificationFailed
        case .verified(let safe):
            return safe
        }
    }
}

Transaction Listener

Listen for transactions that happen outside your app (renewals, family sharing, refunds):

extension SubscriptionManager {
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    await self.validateOnServer(transaction)
                    await transaction.finish()
                    await self.updateSubscriptionStatus()
                } catch {
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }
}

Subscription Status

extension SubscriptionManager {
    func updateSubscriptionStatus() async {
        var hasActiveSubscription = false

        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            if transaction.productType == .autoRenewable {
                hasActiveSubscription = !transaction.isExpired
            }
        }

        await MainActor.run {
            subscriptionStatus = hasActiveSubscription
                ? .active : .expired
        }
    }
}

Android: Google Play Bi

lling

Setup

dependencies {
    implementation "com.android.billingclient:billing-ktx:5.0.0"
}

Billing Client

class SubscriptionManager(private val activity: Activity) {
    private var billingClient: BillingClient? = null
    private val _products = MutableStateFlow<List<ProductDetails>>(emptyList())
    val products: StateFlow<List<ProductDetails>> = _products.asStateFlow()

    fun initialise() {
        billingClient = BillingClient.newBuilder(activity)
            .setListener(purchasesUpdatedListener)
            .enablePendingPurchases()
            .build()

        billingClient?.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(result: BillingResult) {
                if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                    queryProducts()
                    queryExistingPurchases()
                }
            }

            override fun onBillingServiceDisconnected() {
                // Retry connection
            }
        })
    }

    private fun queryProducts() {
        val params = QueryProductDetailsParams.newBuilder()
            .setProductList(
                listOf(
                    QueryProductDetailsParams.Product.newBuilder()
                        .setProductId("monthly_subscription")
                        .setProductType(
                            BillingClient.ProductType.SUBS
                        )
                        .build(),
                    QueryProductDetailsParams.Product.newBuilder()
                        .setProductId("annual_subscription")
                        .setProductType(
                            BillingClient.ProductType.SUBS
                        )
                        .build(),
                )
            )
            .build()

        billingClient?.queryProductDetailsAsync(params) { result, details ->
            if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                _products.value = details
            }
        }
    }
}

Purchase Flow

fun launchPurchase(productDetails: ProductDetails) {
    val offerToken = productDetails.subscriptionOfferDetails
        ?.firstOrNull()?.offerToken ?: return

    val params = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(
            listOf(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                    .setProductDetails(productDetails)
                    .setOfferToken(offerToken)
                    .build()
            )
        )
        .build()

    billingClient?.launchBillingFlow(activity, params)
}

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { result, purchases ->
        when (result.responseCode) {
            BillingClient.BillingResponseCode.OK -> {
                purchases?.forEach { purchase ->
                    handlePurchase(purchase)
                }
            }
            BillingClient.BillingResponseCode.USER_CANCELED -> {
                // User cancelled
            }
            else -> {
                // Error
            }
        }
    }

private fun handlePurchase(purchase: Purchase) {
    if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
        // Validate on server
        validatePurchaseOnServer(purchase.purchaseToken)

        // Acknowledge the purchase
        if (!purchase.isAcknowledged) {
            val params = AcknowledgePurchaseParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()
            billingClient?.acknowledgePurchase(params) { result ->
                // Handle acknowledgement result
            }
        }
    }
}

Server-Sid

e Validation

iOS Receipt Validation

// Node.js server
const axios = require('axios');

async function validateAppleReceipt(receiptData) {
  const payload = {
    'receipt-data': receiptData,
    'password': process.env.APP_STORE_SHARED_SECRET,
    'exclude-old-transactions': true,
  };

  // Try production first
  let response = await axios.post(
    'https://buy.itunes.apple.com/verifyReceipt',
    payload
  );

  // If sandbox receipt, retry with sandbox URL
  if (response.data.status === 21007) {
    response = await axios.post(
      'https://sandbox.itunes.apple.com/verifyReceipt',
      payload
    );
  }

  if (response.data.status !== 0) {
    throw new Error(`Receipt validation failed: ${response.data.status}`);
  }

  const latestReceipt = response.data.latest_receipt_info?.[0];
  if (!latestReceipt) {
    return { isActive: false };
  }

  const expiresDate = new Date(
    parseInt(latestReceipt.expires_date_ms)
  );
  const isActive = expiresDate > new Date();

  return {
    isActive,
    productId: latestReceipt.product_id,
    expiresDate,
    originalTransactionId: latestReceipt.original_transaction_id,
  };
}

Google Play Validation

const { google } = require('googleapis');

async function validateGooglePurchase(
  packageName,
  subscriptionId,
  purchaseToken
) {
  const auth = new google.auth.GoogleAuth({
    keyFile: 'service-account.json',
    scopes: ['https://www.googleapis.com/auth/androidpublisher'],
  });

  const androidPublisher = google.androidpublisher({
    version: 'v3',
    auth,
  });

  const response = await androidPublisher.purchases.subscriptions.get({
    packageName,
    subscriptionId,
    token: purchaseToken,
  });

  const subscription = response.data;
  const expiryTime = parseInt(subscription.expiryTimeMillis);
  const isActive = expiryTime > Date.now();

  return {
    isActive,
    expiryDate: new Date(expiryTime),
    autoRenewing: subscription.autoRenewing,
    paymentState: subscription.paymentState,
    cancelReason: subscription.cancelReason,
  };
}

Subscription Lifecycle Events

Handle the full lifecycle:

  • New subscription: User purchases. Grant access.
  • Renewal: Automatic renewal succeeds. Extend access.
  • Grace period: Payment fails but user retains access for a short period.
  • Billing retry: Platform retries failed payment.
  • Expiration: Subscription expires. Revoke access.
  • Cancellation: User cancels. Access continues until period end.
  • Refund: User gets a refund. Revoke access immediately.
  • Upgrade/Downgrade: User changes plan. Adjust access level.

Server Notifications

Both platforms provide server-to-server notifications for lifecycle events:

Apple: App Store Server Notifications V2

Google: Real-Time Developer Notifications via Cloud Pub/Sub

Set up these notifications to keep your server’s subscription state in sync without polling.

Paywall Design

The paywall is where conversion happens. Key principles:

  1. Show value before price: Explain what the user gets, not what it costs
  2. Offer a free trial: Trials dramatically increase conversion
  3. Highlight the best value: Usually the annual plan
  4. Show savings: “Save 40% with annual” is compelling
  5. Include social proof: User counts, ratings, testimonials
  6. Make it easy to dismiss: Users resent feeling trapped

Testing

iOS Sandbox Testing

Create sandbox tester accounts in App Store Connect. Sandbox subscriptions renew on an accelerated schedule: monthly subscriptions renew every 5 minutes, annual every 30 minutes.

Google Play Testing

Use licence testing in the Google Play Console. Add tester email addresses to bypass real payment processing.

Edge Cases to Test

  • Purchase during poor connectivity
  • App killed during purchase flow
  • Device time manipulation
  • Family sharing scenarios
  • Restore purchases on new device
  • Upgrade and downgrade between plans
  • Cancellation and resubscription

Conclusion

Building a subscription-based mobile app requires careful architecture across client, server, and store integrations. The key principles are: validate purchases server-side, handle all lifecycle events, keep entitlement state synchronised, and test edge cases thoroughly.

StoreKit 2 on iOS and Google Play Billing Library 5 on Android have significantly improved the developer experience, but the underlying complexity of subscription management remains. Invest the time to get the architecture right, and you will have a reliable revenue engine for your app.

For help building subscription-based mobile apps, contact eawesome. We architect and build subscription systems for Australian mobile businesses.