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
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")
}
React Native
npm install react-native-purchases react-native-purchases-ui
cd ios && pod install
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")
Kotlin
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
// In Application.onCreate()
Purchases.configure(
PurchasesConfiguration.Builder(this, "goog_your_api_key")
.build()
)
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 });
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)")
}
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}")
}
}
)
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);
}
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)")
}
}
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()
}
}
)
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);
}
}
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
Kotlin
Purchases.sharedInstance.getCustomerInfoWith(
onError = { error -> Log.e("Billing", error.message) },
onSuccess = { info ->
val isPro = info.entitlements["pro"]?.isActive == true
}
)
React Native
const customerInfo = await Purchases.getCustomerInfo();
const isPro = customerInfo.entitlements.active["pro"] !== undefined;
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)
}
Kotlin
Purchases.sharedInstance.updatedCustomerInfoListener =
UpdatedCustomerInfoListener { customerInfo ->
updateUIForEntitlements(customerInfo)
}
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()
}
}
}
Even simpler: auto-present the paywall only if the user doesn't have the entitlement:
ContentView()
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "pro")
Android Compose
PaywallDialog(
PaywallDialogOptions.Builder()
.setRequiredEntitlementIdentifier("pro")
.setListener(object : PaywallListener {
override fun onPurchaseCompleted(
customerInfo: CustomerInfo,
storeTransaction: StoreTransaction
) {
navigateToProContent()
}
})
.build()
)
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
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()
// React Native
const info = await Purchases.restorePurchases();
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)