Adding subscriptions to an iOS app can be complex. RevenueCat simplifies the process by handling receipt validation, subscription status, and cross-platform support. Here's how we implemented RevenueCat in Skyscraper for iOS.

What is RevenueCat?

RevenueCat provides a backend and SDK wrapper around StoreKit and Google Play Billing. Instead of building your own server infrastructure for receipt validation and subscription management, RevenueCat handles it all.

Key benefits:

  • Server-side receipt validation – No need to build your own backend
  • Cross-platform support – iOS, Android, and web
  • Real-time subscription status – Webhook and SDK updates
  • Analytics dashboard – Track revenue, churn, and conversions
  • StoreKit 2 support – Modern Apple APIs

Installation

Install RevenueCat via Swift Package Manager:

  1. In Xcode, select File → Add Packages...
  2. Enter the repository URL: https://github.com/RevenueCat/purchases-ios.git
  3. Set Dependency Rule to "Up to next major" with version 4.0.0 < 5.0.0
  4. Select RevenueCat when prompted
  5. Enable In-App Purchase capability in Project Settings → Capabilities

Creating a RevenueCat Manager

We use a singleton manager class to handle all RevenueCat interactions. Here's the structure we use in Skyscraper:

import RevenueCat
import Combine

// Subscription status enum
enum SubscriptionStatus: Equatable {
    case unknown
    case notSubscribed
    case subscribed(tier: SubscriptionTier, expirationDate: Date?)
    case expired(tier: SubscriptionTier, expirationDate: Date?)

    var isActive: Bool {
        if case .subscribed = self { return true }
        return false
    }
}

// Subscription tiers
enum SubscriptionTier: String, CaseIterable {
    case monthly = "monthly"
    case yearly = "yearly"

    var productIdentifier: String {
        switch self {
        case .monthly: return "monthly"
        case .yearly: return "yearly"
        }
    }
}

Manager Class Implementation

The manager handles configuration, customer info, offerings, and purchases:

@MainActor
class RevenueCatManager: NSObject, ObservableObject {

    static let shared = RevenueCatManager()
    private static let apiKey = "your_api_key_here"
    static let proEntitlementID = "Your Pro Entitlement"

    @Published private(set) var subscriptionStatus: SubscriptionStatus = .unknown
    @Published private(set) var isProUser: Bool = false
    @Published private(set) var customerInfo: CustomerInfo?
    @Published private(set) var offerings: Offerings?
    @Published private(set) var currentOffering: Offering?
    @Published private(set) var isPurchasing: Bool = false

    private override init() {
        super.init()
    }
}

Configuration at App Launch

Configure RevenueCat once at app launch:

func configure() {
    #if DEBUG
    Purchases.logLevel = .debug
    #else
    Purchases.logLevel = .info
    #endif

    // Configure with API key
    Purchases.configure(withAPIKey: Self.apiKey)

    // Set delegate for updates
    Purchases.shared.delegate = self

    // Fetch initial data
    Task {
        await refreshCustomerInfo()
        await fetchOfferings()
    }
}

In your App struct or AppDelegate:

@main
struct MyApp: App {
    init() {
        RevenueCatManager.shared.configure()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(RevenueCatManager.shared)
        }
    }
}

Fetching Offerings

Offerings contain the packages (subscription options) you've configured in the RevenueCat dashboard:

func fetchOfferings() async {
    do {
        let offerings = try await Purchases.shared.offerings()
        self.offerings = offerings
        self.currentOffering = offerings.current

        if let current = offerings.current {
            print("Fetched \(current.availablePackages.count) packages")
        }
    } catch {
        print("Failed to fetch offerings: \(error)")
    }
}

func getPackage(for tier: SubscriptionTier) -> Package? {
    guard let offering = currentOffering else { return nil }

    switch tier {
    case .monthly:
        return offering.monthly ?? offering.package(identifier: "monthly")
    case .yearly:
        return offering.annual ?? offering.package(identifier: "yearly")
    }
}

Handling Purchases

Process purchases and update subscription status:

func purchase(package: Package) async -> Bool {
    isPurchasing = true

    do {
        let result = try await Purchases.shared.purchase(package: package)

        if !result.userCancelled {
            updateSubscriptionStatus(from: result.customerInfo)
            isPurchasing = false
            return true
        } else {
            isPurchasing = false
            return false
        }
    } catch {
        print("Purchase failed: \(error)")
        isPurchasing = false
        return false
    }
}

func purchase(tier: SubscriptionTier) async -> Bool {
    guard let package = getPackage(for: tier) else {
        return false
    }
    return await purchase(package: package)
}

Checking Entitlements

RevenueCat uses entitlements to grant access to features:

private func updateSubscriptionStatus(from customerInfo: CustomerInfo) {
    self.customerInfo = customerInfo

    // Check for pro entitlement
    if let entitlement = customerInfo.entitlements[Self.proEntitlementID],
       entitlement.isActive {
        isProUser = true

        let tier = determineTier(from: entitlement.productIdentifier)
        subscriptionStatus = .subscribed(
            tier: tier,
            expirationDate: entitlement.expirationDate
        )
    } else {
        isProUser = false
        subscriptionStatus = .notSubscribed
    }
}

func hasProAccess() -> Bool {
    return customerInfo?.entitlements[Self.proEntitlementID]?.isActive == true
}

Restore Purchases

Always provide a way for users to restore previous purchases:

func restorePurchases() async -> Bool {
    isPurchasing = true

    do {
        let customerInfo = try await Purchases.shared.restorePurchases()
        updateSubscriptionStatus(from: customerInfo)

        let hasActive = customerInfo.entitlements[Self.proEntitlementID]?.isActive == true
        isPurchasing = false
        return hasActive
    } catch {
        print("Restore failed: \(error)")
        isPurchasing = false
        return false
    }
}

Listening for Updates

Subscribe to real-time customer info updates:

// Using PurchasesDelegate
extension RevenueCatManager: PurchasesDelegate {
    nonisolated func purchases(
        _ purchases: Purchases,
        receivedUpdated customerInfo: CustomerInfo
    ) {
        Task { @MainActor in
            self.updateSubscriptionStatus(from: customerInfo)
        }
    }
}

// Or using AsyncSequence
func startListeningForUpdates() {
    Task {
        for await customerInfo in Purchases.shared.customerInfoStream {
            await MainActor.run {
                self.updateSubscriptionStatus(from: customerInfo)
            }
        }
    }
}

Package Helper Extensions

Useful extensions for displaying package information:

extension Package {
    var formattedPrice: String {
        return storeProduct.localizedPriceString
    }

    var formattedMonthlyPrice: String? {
        guard let period = storeProduct.subscriptionPeriod,
              period.unit == .year else { return nil }

        let monthly = storeProduct.price as Decimal / 12
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = storeProduct.priceFormatter?.locale ?? .current

        return formatter.string(from: monthly as NSDecimalNumber)
    }

    var durationDescription: String {
        guard let period = storeProduct.subscriptionPeriod else {
            return "One-time"
        }

        switch period.unit {
        case .month: return period.value == 1 ? "Monthly" : "\(period.value) Months"
        case .year: return period.value == 1 ? "Yearly" : "\(period.value) Years"
        default: return "Unknown"
        }
    }
}

Best Practices

  • Configure once: Only configure RevenueCat once at app launch
  • Use entitlements: Gate features on entitlements, not product IDs
  • Handle errors: Always catch and display purchase errors
  • Enable server notifications: Set up App Store Server Notifications for real-time updates
  • Test thoroughly: Use sandbox testing before going live
  • Provide restore: Always include a restore purchases button

SwiftUI Paywall Example

A simple paywall view using the manager:

struct PaywallView: View {
    @EnvironmentObject var revenueCat: RevenueCatManager

    var body: some View {
        VStack {
            if revenueCat.isProUser {
                Text("You're a Pro subscriber!")
            } else if let offering = revenueCat.currentOffering {
                ForEach(offering.availablePackages, id: \.identifier) { package in
                    Button {
                        Task {
                            await revenueCat.purchase(package: package)
                        }
                    } label: {
                        VStack {
                            Text(package.durationDescription)
                            Text(package.formattedPrice)
                        }
                    }
                    .disabled(revenueCat.isPurchasing)
                }

                Button("Restore Purchases") {
                    Task { await revenueCat.restorePurchases() }
                }
            }
        }
    }
}

Resources

RevenueCat makes subscription management straightforward. Focus on building your app while RevenueCat handles the infrastructure.

Try Skyscraper for iOS →