How to Check User Subscription Status on iOS

Introduction

The CustomerInfo object is RevenueCat's central source of truth for a user's subscription state. It holds every active entitlement, expiry date, trial status, and renewal flag for the current user. Any time you need to determine whether to show a paywall or unlock a premium feature, you read from CustomerInfo.

There are three ways to get CustomerInfo:

  1. On-demand: Call Purchases.shared.getCustomerInfo() when you need it.
  2. Reactively: Listen for Purchases.customerInfoUpdatedNotification to get push updates.
  3. After a transaction: The result of purchase(package:) and restorePurchases() both return an updated CustomerInfo.
Never cache subscription status locally. Always read entitlements from RevenueCat. RevenueCat handles caching internally and keeps data fresh, while local caches can go stale and lead to users being incorrectly denied or granted access.

Basic Entitlement Check

The most common pattern is a simple async function that returns a boolean. Call this before showing premium content:

swift
// Check if the user has an active "premium" entitlement
func checkPremiumStatus() async -> Bool {
    do {
        let customerInfo = try await Purchases.shared.getCustomerInfo()
        return customerInfo.entitlements["premium"]?.isActive == true
    } catch {
        print("Error fetching customer info: \(error)")
        // Fail safely — do not grant access on error
        return false
    }
}

// Usage
Task {
    let isPremium = await checkPremiumStatus()
    if isPremium {
        showPremiumContent()
    } else {
        showPaywall()
    }
}
Use entitlement IDs, not product IDs. The entitlement identifier (e.g., "premium") is configured in your RevenueCat dashboard and remains stable as you add, remove, or change products. Gating on product IDs creates brittle code that breaks whenever you update your product catalog.

Reactive Updates with ObservableObject

For a SwiftUI app, the recommended pattern is an ObservableObject that holds subscription state as a @Published property and listens for RevenueCat update notifications. This keeps your entire UI in sync automatically without polling.

swift
import SwiftUI
import RevenueCat

class SubscriptionManager: ObservableObject {
    @Published var isPremium: Bool = false
    @Published var isLoading: Bool = true

    init() {
        fetchCustomerInfo()

        // Listen for real-time updates (renewals, cancellations, etc.)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleCustomerInfoUpdate),
            name: Purchases.customerInfoUpdatedNotification,
            object: nil
        )
    }

    func fetchCustomerInfo() {
        Task { @MainActor in
            do {
                let customerInfo = try await Purchases.shared.getCustomerInfo()
                self.isPremium = customerInfo.entitlements["premium"]?.isActive == true
                self.isLoading = false
            } catch {
                // Keep isLoading false so the UI doesn't hang
                self.isLoading = false
                print("Error fetching customer info: \(error)")
            }
        }
    }

    @objc private func handleCustomerInfoUpdate(_ notification: Notification) {
        if let customerInfo = notification.userInfo?["customerInfo"] as? CustomerInfo {
            DispatchQueue.main.async {
                self.isPremium = customerInfo.entitlements["premium"]?.isActive == true
            }
        }
    }
}

Instantiate this manager once at the app level and inject it via the SwiftUI environment so every screen in your app reads from the same source:

swift
@main
struct MyApp: App {
    @StateObject private var subscriptionManager = SubscriptionManager()

    init() {
        Purchases.configure(withAPIKey: "appl_your_key_here")
    }

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

Gating Features in SwiftUI

With SubscriptionManager in the environment, any view can read subscription status and conditionally show content:

swift
struct ContentView: View {
    @EnvironmentObject var subscriptionManager: SubscriptionManager
    @State private var showPaywall = false

    var body: some View {
        Group {
            if subscriptionManager.isLoading {
                ProgressView("Loading...")
            } else if subscriptionManager.isPremium {
                PremiumContentView()
            } else {
                FreeContentView()
                    .toolbar {
                        ToolbarItem(placement: .navigationBarTrailing) {
                            Button("Upgrade") {
                                showPaywall = true
                            }
                        }
                    }
            }
        }
        .sheet(isPresented: $showPaywall) {
            PaywallView()
        }
    }
}

// A deeply nested view can also access subscription state
struct PremiumFeatureButton: View {
    @EnvironmentObject var subscriptionManager: SubscriptionManager
    @State private var showPaywall = false

    var body: some View {
        Button("Export to PDF") {
            if subscriptionManager.isPremium {
                exportToPDF()
            } else {
                showPaywall = true
            }
        }
        .sheet(isPresented: $showPaywall) {
            PaywallView()
        }
    }

    private func exportToPDF() { /* ... */ }
}

Expiry Dates, Trials, and Grace Periods

The EntitlementInfo object (accessed via customerInfo.entitlements["premium"]) exposes rich metadata beyond just isActive. Use these properties to build smarter UI:

swift
let customerInfo = try await Purchases.shared.getCustomerInfo()

if let premium = customerInfo.entitlements["premium"] {
    print("Is active: \(premium.isActive)")

    // Expiry date — nil for lifetime purchases
    if let expiry = premium.expirationDate {
        print("Expires: \(expiry)")
    }

    // Will the subscription auto-renew?
    print("Will renew: \(premium.willRenew)")

    // Period type: .normal, .trial, .intro
    switch premium.periodType {
    case .trial:
        print("User is in a free trial")
    case .intro:
        print("User is in an introductory pricing period")
    case .normal:
        print("User is on a regular paid subscription")
    default:
        break
    }

    // Which store did the purchase come from?
    print("Store: \(premium.store)") // .appStore, .playStore, .stripe, etc.

    // Is the subscription in a billing retry / grace period?
    print("Unsubscribe detected: \(premium.unsubscribeDetectedAt?.description ?? "no")")
    print("Billing issue detected: \(premium.billingIssueDetectedAt?.description ?? "no")")
}

Use periodType == .trial to show a "Trial Active" badge in your UI, or billingIssueDetectedAt != nil to nudge users to update their payment method.


Best Practices

Never store subscription status locally. Do not write isPremium to UserDefaults or your own database. RevenueCat's SDK caches CustomerInfo on disk and keeps it fresh automatically.
Refresh on app foreground. Call getCustomerInfo() in your sceneDidBecomeActive or .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) handler to ensure the state is current when users switch back to your app after managing subscriptions in the App Store or Settings.
Handle errors gracefully. If getCustomerInfo() throws a network error, fall back to the RevenueCat SDK's cached value by catching the error and reading from a previously known state. Do not block the user from accessing features they already paid for due to a transient network failure.

Related Guides