DEV Community

Dennis
Dennis

Posted on

Cross-Platform Subscription Integration: A Developer Guide

Subscription billing across iOS, Android, and React Native is one of those problems that sounds straightforward until you actually start implementing it. Each platform has its own payment API, its own receipt format, its own edge cases around renewals, billing retries, and refunds.

RevenueCat abstracts most of this pain away. This guide walks through the full implementation: SDK setup, fetching offerings, handling purchases, checking entitlements, and displaying paywalls. Working code for all three platforms.

Project Setup

iOS (Swift Package Manager)

Add RevenueCat to your Xcode project via SPM:

https://github.com/RevenueCat/purchases-ios.git
Enter fullscreen mode Exit fullscreen mode

Select both RevenueCat and RevenueCatUI packages. Minimum deployment target: iOS 13.

Android (Gradle)

In your app-level build.gradle.kts:

dependencies {
    implementation("com.revenuecat.purchases:purchases:8.0.0")
    implementation("com.revenuecat.purchases:purchases-ui:8.0.0")
}
Enter fullscreen mode Exit fullscreen mode

React Native

npm install react-native-purchases react-native-purchases-ui
cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

SDK Initialization

Initialize RevenueCat as early as possible in your app lifecycle. Each platform uses a different API key prefix: appl_ for iOS, goog_ for Android.

Swift

import RevenueCat
import RevenueCatUI

// In your App init or AppDelegate
Purchases.logLevel = .debug  // remove in production
Purchases.configure(withAPIKey: "appl_your_api_key")
Enter fullscreen mode Exit fullscreen mode

Kotlin

import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration

// In Application.onCreate()
Purchases.configure(
    PurchasesConfiguration.Builder(this, "goog_your_api_key")
        .build()
)
Enter fullscreen mode Exit fullscreen mode

React Native

import Purchases from 'react-native-purchases';
import { Platform } from 'react-native';

const apiKey = Platform.OS === 'ios'
    ? 'appl_your_api_key'
    : 'goog_your_api_key';

await Purchases.configure({ apiKey });
Enter fullscreen mode Exit fullscreen mode

One thing to note: you can pass an appUserID parameter if you have your own authentication system. If you omit it, RevenueCat generates an anonymous ID and handles the identity merging when your user eventually logs in.

Fetching Offerings

Offerings are configured in the RevenueCat dashboard. The SDK fetches them at runtime, which means you can change pricing, packages, and trial configurations without shipping an app update.

Swift

do {
    let offerings = try await Purchases.shared.offerings()
    guard let packages = offerings.current?.availablePackages else {
        print("No packages available")
        return
    }
    // packages contains .monthly, .annual, etc.
    for package in packages {
        print("\(package.identifier): \(package.localizedPriceString)")
    }
} catch {
    print("Failed to fetch offerings: \(error)")
}
Enter fullscreen mode Exit fullscreen mode

Kotlin

Purchases.sharedInstance.getOfferingsWith(
    onError = { error ->
        Log.e("Billing", "Error: ${error.message}")
    },
    onSuccess = { offerings ->
        val packages = offerings.current?.availablePackages ?: return@getOfferingsWith
        packages.forEach { pkg ->
            Log.d("Billing", "${pkg.identifier}: ${pkg.product.price}")
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

React Native

try {
    const offerings = await Purchases.getOfferings();
    if (offerings.current?.availablePackages.length > 0) {
        const packages = offerings.current.availablePackages;
        packages.forEach(pkg => {
            console.log(`${pkg.identifier}: ${pkg.product.priceString}`);
        });
    }
} catch (e) {
    console.error('Failed to fetch offerings:', e);
}
Enter fullscreen mode Exit fullscreen mode

Making Purchases

The purchase flow handles the platform-specific payment sheet, receipt validation, and entitlement activation in a single call. The response includes updated CustomerInfo with the subscriber's current entitlement state.

Swift

do {
    let result = try await Purchases.shared.purchase(package: selectedPackage)
    if result.customerInfo.entitlements["pro"]?.isActive == true {
        // Unlock premium features
        navigateToProContent()
    }
} catch let error as RevenueCat.ErrorCode {
    if error != .purchaseCancelledError {
        showAlert("Purchase failed: \(error.localizedDescription)")
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin

Purchases.sharedInstance.purchaseWith(
    PurchaseParams.Builder(activity, selectedPackage).build(),
    onError = { error, userCancelled ->
        if (!userCancelled) {
            showError("Purchase failed: ${error.message}")
        }
    },
    onSuccess = { _, customerInfo ->
        if (customerInfo.entitlements["pro"]?.isActive == true) {
            navigateToProContent()
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

React Native

try {
    const { customerInfo } = await Purchases.purchasePackage(selectedPackage);
    if (customerInfo.entitlements.active["pro"] !== undefined) {
        navigateToProContent();
    }
} catch (e) {
    if (!e.userCancelled) {
        Alert.alert('Purchase failed', e.message);
    }
}
Enter fullscreen mode Exit fullscreen mode

A common mistake: checking for a specific product ID instead of an entitlement. Always use entitlements. They decouple your access logic from your product catalog, so you can add new products, change prices, or restructure offerings without code changes.

Checking Entitlement Status

You'll check this on app launch, when the user navigates to gated content, and whenever CustomerInfo updates.

Swift

let customerInfo = try await Purchases.shared.customerInfo()
let isPro = customerInfo.entitlements["pro"]?.isActive == true
Enter fullscreen mode Exit fullscreen mode

Kotlin

Purchases.sharedInstance.getCustomerInfoWith(
    onError = { error -> Log.e("Billing", error.message) },
    onSuccess = { info ->
        val isPro = info.entitlements["pro"]?.isActive == true
    }
)
Enter fullscreen mode Exit fullscreen mode

React Native

const customerInfo = await Purchases.getCustomerInfo();
const isPro = customerInfo.entitlements.active["pro"] !== undefined;
Enter fullscreen mode Exit fullscreen mode

The SDK caches CustomerInfo locally with a five-minute TTL, so these calls are fast and don't always hit the network.

For real-time updates (like a subscription purchased on another device), set up a listener:

Swift

Purchases.shared.delegate = self

func purchases(_ purchases: Purchases,
               receivedUpdated customerInfo: CustomerInfo) {
    updateUIForEntitlements(customerInfo)
}
Enter fullscreen mode Exit fullscreen mode

Kotlin

Purchases.sharedInstance.updatedCustomerInfoListener =
    UpdatedCustomerInfoListener { customerInfo ->
        updateUIForEntitlements(customerInfo)
    }
Enter fullscreen mode Exit fullscreen mode

Displaying Paywalls

RevenueCat includes pre-built paywall templates that you configure from the dashboard. This is the fastest path to a working paywall, and it supports A/B testing without code changes.

SwiftUI

import RevenueCatUI

struct SettingsView: View {
    @State private var showPaywall = false

    var body: some View {
        Button("Upgrade to Pro") {
            showPaywall = true
        }
        .sheet(isPresented: $showPaywall) {
            PaywallView()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Even simpler: auto-present the paywall only if the user doesn't have the entitlement:

ContentView()
    .presentPaywallIfNeeded(requiredEntitlementIdentifier: "pro")
Enter fullscreen mode Exit fullscreen mode

Android Compose

PaywallDialog(
    PaywallDialogOptions.Builder()
        .setRequiredEntitlementIdentifier("pro")
        .setListener(object : PaywallListener {
            override fun onPurchaseCompleted(
                customerInfo: CustomerInfo,
                storeTransaction: StoreTransaction
            ) {
                navigateToProContent()
            }
        })
        .build()
)
Enter fullscreen mode Exit fullscreen mode

React Native

import RevenueCatUI from 'react-native-purchases-ui';

const result = await RevenueCatUI.presentPaywallIfNeeded({
    requiredEntitlementIdentifier: "pro"
});
// result tells you if a purchase was made, cancelled, or not shown
Enter fullscreen mode Exit fullscreen mode

Restore Purchases

Apple requires a visible "Restore Purchases" button. This handles users who reinstall or switch devices:

// Swift
let info = try await Purchases.shared.restorePurchases()
Enter fullscreen mode Exit fullscreen mode
// React Native
const info = await Purchases.restorePurchases();
Enter fullscreen mode Exit fullscreen mode

Things That Will Bite You

Forgetting to acknowledge on Android. Google Play automatically refunds purchases not acknowledged within three days. RevenueCat handles this for you, but if you're mixing SDK and manual BillingClient calls, watch out.

Testing with real sandbox accounts. Both Apple and Google have sandbox environments with their own quirks. Apple sandbox subscriptions renew on accelerated timelines (monthly subs renew every 5 minutes). Google test purchases behave differently depending on your license tester configuration.

Not handling billing retries. When a subscriber's payment fails, both stores retry over a grace period. RevenueCat's billingIssueDetectedAt field on EntitlementInfo tells you when a billing problem started. Use this to show a gentle "update your payment method" prompt instead of immediately revoking access.

Hardcoding prices. Always use localizedPriceString from the SDK. Prices vary by region, and Apple/Google handle currency conversion and tax. Display what the store tells you, not what you think the price is.

Wrapping Up

The core integration pattern is the same across all three platforms: initialize the SDK, fetch offerings, present a paywall or custom purchase UI, make the purchase, check the entitlement. RevenueCat normalizes the platform differences, and the dashboard gives you control over pricing and paywall presentation without redeploying.

Full documentation lives at docs.revenuecat.com. The sample apps in RevenueCat's GitHub org are worth cloning if you want a running reference implementation for any platform.

Top comments (0)