Implementing In-App Purchases for iOS and Android

In-app purchases (IAP) are the dominant monetisation model for mobile apps. Whether you are selling premium features, consumable items, or subscriptions, both Apple and Google provide robust billing APIs. However, implementing IAP correctly is more complex than most developers expect.

This guide walks through the complete implementation for both platforms, covering product configuration, purchase flows, receipt validation, and the gotchas that trip up first-time implementers.

IAP Product Types

Both platforms support similar product types:

Consumable: Can be purchased multiple times. Used up and repurchased (e.g., virtual currency, extra lives). Not restored on new devices.

Non-Consumable: Purchased once, available forever. Restored on new devices (e.g., premium feature unlock, ad removal). Apple requires a “Restore Purchases” button.

Auto-Renewable Subscription: Recurring charge at a set interval (weekly, monthly, yearly). Automatically renews until cancelled. The primary model for ongoing content or services.

Non-Renewing Subscription: Time-limited access that does not auto-renew. The user must manually repurchase. Less common and more complex to manage.

iOS: StoreKit Implementation

iOS: StoreKit Implementation Infographic

Configuring Products in App Store Connect

  1. Navigate to your app in App Store Connect
  2. Go to Features, then In-App Purchases
  3. Create each product with:
    • A unique Product ID (e.g., au.com.yourapp.premium_monthly)
    • A display name and description
    • A price tier (supports Australian Dollar tiers)
    • For subscriptions: duration and subscription group

Use reverse domain notation for product IDs. Once created, product IDs cannot be reused, even if deleted.

Fetching Products

import StoreKit

class StoreManager: NSObject, ObservableObject, SKProductsRequestDelegate {
    @Published var products: [SKProduct] = []
    @Published var purchaseState: PurchaseState = .idle

    private var productRequest: SKProductsRequest?

    func fetchProducts() {
        let productIDs: Set<String> = [
            "au.com.yourapp.premium_monthly",
            "au.com.yourapp.premium_yearly",
            "au.com.yourapp.remove_ads"
        ]

        productRequest = SKProductsRequest(productIdentifiers: productIDs)
        productRequest?.delegate = self
        productRequest?.start()
    }

    func productsRequest(
        _ request: SKProductsRequest,
        didReceive response: SKProductsResponse
    ) {
        DispatchQueue.main.async {
            self.products = response.products.sorted { $0.price.doubleValue < $1.price.doubleValue }
        }

        // Log invalid product IDs for debugging
        if !response.invalidProductIdentifiers.isEmpty {
            print("Invalid product IDs: \(response.invalidProductIdentifiers)")
        }
    }
}

Displaying Prices

Always use the product’s localised price, never hardcode prices:

extension SKProduct {
    var localizedPrice: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = priceLocale
        return formatter.string(from: price) ?? "\(price)"
    }
}

// Usage in UI
Text(product.localizedPrice) // Displays "A$9.99" for Australian users

Making a Purchase

extension StoreManager: SKPaymentTransactionObserver {

    func purchase(product: SKProduct) {
        guard SKPaymentQueue.canMakePayments() else {
            purchaseState = .failed("Purchases are disabled on this device")
            return
        }

        let payment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment)
        purchaseState = .purchasing
    }

    func paymentQueue(
        _ queue: SKPaymentQueue,
        updatedTransactions transactions: [SKPaymentTransaction]
    ) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .purchased:
                handlePurchased(transaction)
            case .failed:
                handleFailed(transaction)
            case .restored:
                handleRestored(transaction)
            case .deferred:
                // Ask-to-Buy: waiting for parental approval
                purchaseState = .deferred
            case .purchasing:
                break
            @unknown default:
                break
            }
        }
    }

    private func handlePurchased(_ transaction: SKPaymentTransaction) {
        // Validate receipt server-side
        validateReceipt { success in
            if success {
                self.unlockContent(for: transaction.payment.productIdentifier)
                self.purchaseState = .purchased
            } else {
                self.purchaseState = .failed("Receipt validation failed")
            }
            SKPaymentQueue.default().finishTransaction(transaction)
        }
    }

    private func handleFailed(_ transaction: SKPaymentTransaction) {
        if let error = transaction.error as? SKError, error.code != .paymentCancelled {
            purchaseState = .failed(error.localizedDescription)
        } else {
            purchaseState = .idle
        }
        SKPaymentQueue.default().finishTransaction(transaction)
    }

    private func handleRestored(_ transaction: SKPaymentTransaction) {
        unlockContent(for: transaction.payment.productIdentifier)
        SKPaymentQueue.default().finishTransaction(transaction)
    }
}

Restoring Purchases

Apple requires all apps with non-consumable purchases or subscriptions to include a Restore Purchases mechanism:

func restorePurchases() {
    SKPaymentQueue.default().restoreCompletedTransactions()
}

func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
    if queue.transactions.isEmpty {
        purchaseState = .failed("No purchases to restore")
    } else {
        purchaseState = .restored
    }
}

Receipt Validation

Never validate receipts only on the device. Client-side validation can be bypassed. Always validate server-side.

private func validateReceipt(completion: @escaping (Bool) -> Void) {
    guard let receiptURL = Bundle.main.appStoreReceiptURL,
          let receiptData = try? Data(contentsOf: receiptURL)
    else {
        completion(false)
        return
    }

    let receiptString = receiptData.base64EncodedString()

    // Send to your server for validation
    var request = URLRequest(url: URL(string: "https://api.yourapp.com/validate-receipt")!)
    request.httpMethod = "POST"
    request.httpBody = try? JSONEncoder().encode(["receipt": receiptString])
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data,
              let result = try? JSONDecoder().decode(ValidationResult.self, from: data)
        else {
            completion(false)
            return
        }
        completion(result.isValid)
    }.resume()
}

Your server validates with Apple:

// Node.js server-side receipt validation
const validateAppleReceipt = async (receiptData) => {
  // Try production first
  let response = await fetch('https://buy.itunes.apple.com/verifyReceipt', {
    method: 'POST',
    body: JSON.stringify({
      'receipt-data': receiptData,
      'password': process.env.APP_SHARED_SECRET,
    }),
  });

  let result = await response.json();

  // Status 21007 means sandbox receipt sent to production
  if (result.status === 21007) {
    response = await fetch('https://sandbox.itunes.apple.com/verifyReceipt', {
      method: 'POST',
      body: JSON.stringify({
        'receipt-data': receiptData,
        'password': process.env.APP_SHARED_SECRET,
      }),
    });
    result = await response.json();
  }

  return result.status === 0;
};

Android: Google Play Billing Librar

Android: Google Play Billing Library Infographic y

Configuring Products in Google Play Console

  1. Navigate to your app in the Google Play Console
  2. Go to Monetise, then Products, then In-app products (or Subscriptions)
  3. Create each product with a Product ID, name, description, and price
  4. Activate the product

Setting Up the Billing Client

// Add dependency
// implementation 'com.android.billingclient:billing-ktx:4.0.0'

class BillingManager(private val context: Context) : PurchasesUpdatedListener {
    private var billingClient: BillingClient = BillingClient.newBuilder(context)
        .setListener(this)
        .enablePendingPurchases()
        .build()

    private var productDetails: List<SkuDetails> = emptyList()

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

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

    private fun queryProducts() {
        val params = SkuDetailsParams.newBuilder()
            .setSkusList(listOf(
                "premium_monthly",
                "premium_yearly",
                "remove_ads"
            ))
            .setType(BillingClient.SkuType.SUBS)
            .build()

        billingClient.querySkuDetailsAsync(params) { billingResult, skuDetailsList ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                productDetails = skuDetailsList ?: emptyList()
            }
        }
    }
}

Launching the Purchase Flow

fun purchaseProduct(activity: Activity, skuDetails: SkuDetails) {
    val flowParams = BillingFlowParams.newBuilder()
        .setSkuDetails(skuDetails)
        .build()

    billingClient.launchBillingFlow(activity, flowParams)
}

// Handle purchase result
override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: MutableList<Purchase>?
) {
    when (billingResult.responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            purchases?.forEach { purchase ->
                handlePurchase(purchase)
            }
        }
        BillingClient.BillingResponseCode.USER_CANCELED -> {
            // User cancelled, do nothing
        }
        else -> {
            // Handle error
            Log.e("Billing", "Purchase failed: ${billingResult.debugMessage}")
        }
    }
}

Acknowledging and Verifying Purchases

Google requires purchases to be acknowledged within 3 days, or they are automatically refunded:

private fun handlePurchase(purchase: Purchase) {
    if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
        // Verify on your server first
        verifyPurchaseOnServer(purchase) { isValid ->
            if (isValid) {
                // Acknowledge the purchase
                if (!purchase.isAcknowledged) {
                    val params = AcknowledgePurchaseParams.newBuilder()
                        .setPurchaseToken(purchase.purchaseToken)
                        .build()

                    billingClient.acknowledgePurchase(params) { billingResult ->
                        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                            unlockContent(purchase.skus.first())
                        }
                    }
                }
            }
        }
    }
}

Server-side verification with Google:

// Node.js: Verify Google Play purchase
const { google } = require('googleapis');

const verifyGooglePurchase = async (packageName, productId, 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.products.get({
    packageName,
    productId,
    token: purchaseToken,
  });

  return response.data.purchaseState === 0; // 0 = purchased
};

Subscription Management

Subscription Status Checking

Check subscription status on app launch and periodically:

// iOS: Check subscription status from receipt
func checkSubscriptionStatus() {
    guard let receiptURL = Bundle.main.appStoreReceiptURL,
          let receiptData = try? Data(contentsOf: receiptURL)
    else {
        // No receipt: user has no purchases
        updateSubscriptionStatus(.inactive)
        return
    }

    // Send to server for validation and status check
    validateSubscription(receiptData: receiptData) { status in
        self.updateSubscriptionStatus(status)
    }
}

Handling Subscription Changes

Users can upgrade, downgrade, or cancel subscriptions. Handle these events:

Server-side notifications: Both Apple (App Store Server Notifications) and Google (Real-time Developer Notifications) can send webhook notifications when subscription events occur. Implement these for accurate, real-time status tracking.

// Express.js: Handle Apple subscription notification
app.post('/apple-webhook', (req, res) => {
  const notification = req.body;

  switch (notification.notification_type) {
    case 'INITIAL_BUY':
      activateSubscription(notification);
      break;
    case 'DID_RENEW':
      renewSubscription(notification);
      break;
    case 'CANCEL':
    case 'DID_FAIL_TO_RENEW':
      handleCancellation(notification);
      break;
    case 'DID_CHANGE_RENEWAL_STATUS':
      updateRenewalStatus(notification);
      break;
  }

  res.status(200).send();
});

Cross-Platform Considerations for React Native

For React Native apps, use react-native-iap to handle purchases on both platforms:

import * as IAP from 'react-native-iap';

const productIds = Platform.select({
  ios: ['au.com.yourapp.premium_monthly'],
  android: ['premium_monthly'],
});

// Initialise
await IAP.initConnection();

// Fetch products
const products = await IAP.getSubscriptions(productIds);

// Purchase
await IAP.requestSubscription(productIds[0]);

// Listen for purchase updates
const purchaseListener = IAP.purchaseUpdatedListener(async (purchase) => {
  const receipt = purchase.transactionReceipt;
  if (receipt) {
    // Validate server-side
    const isValid = await validateReceipt(receipt);
    if (isValid) {
      await IAP.finishTransaction(purchase);
      unlockPremium();
    }
  }
});

// Clean up
purchaseListener.remove();
await IAP.endConnection();

Common IAP Pitfalls

  1. Not finishing transactions. Unfinished transactions cause the purchase dialog to reappear. Always call finishTransaction.

  2. Hardcoding prices. Prices vary by region and can change. Always display the price from the product object.

  3. Client-side only validation. Receipts validated only on the device can be forged. Always validate server-side.

  4. Missing Restore Purchases. Apple will reject your app without it.

  5. Not handling deferred purchases. The Ask to Buy feature (for family sharing) means a purchase might not complete immediately. Handle the deferred state.

  6. Ignoring subscription state changes. Users cancel, payment methods fail, subscriptions expire. Keep subscription status accurate with server-side notifications.

  7. Not testing with sandbox accounts. Both platforms provide sandbox environments. Test thoroughly before launch.

Testing IAP

iOS Sandbox Testing

Create sandbox tester accounts in App Store Connect. Sandbox purchases do not charge real money and can be configured to accelerate subscription renewal (monthly subscriptions renew every 5 minutes in sandbox).

Android Test Purchases

Add your Google account as a licence tester in the Google Play Console. Test purchases do not charge your card.

Always test these scenarios:

  • Successful purchase
  • Cancelled purchase
  • Failed payment
  • Restore purchases on a new device
  • Subscription renewal
  • Subscription cancellation
  • Upgrade and downgrade between subscription tiers

In-app purchases are a critical revenue path for most mobile apps. Getting the implementation right requires attention to detail across both platforms. At eawesome, we have implemented IAP for numerous Australian apps and know the common pitfalls to avoid.