You've integrated RevenueCat v5, your purchases appear to succeed in the simulator, your entitlement checks return true, and you ship. Then production support tickets arrive: customers were charged but never unlocked premium features. Others are bypassing paywalls entirely using MiTM proxies. Your CI pipeline breaks every time someone bumps the simulator to iOS 18.4.

Each of these failures has a precise technical cause — and each one has a documented fix. This guide covers the three most impactful gaps between "it works in development" and "it works in production": the exact failure modes when you omit the In-App Purchase Key, the behavioral contract of purchasesAreCompletedBy and what it does to SK2 transaction finishing, and both Trusted Entitlement verification modes with the code you need to act on each.

Target reader: Senior iOS developer, Swift, iOS 15+, RevenueCat SDK v5.


1. The In-App Purchase Key: What Actually Breaks Without It

The most dangerous misconfiguration in a SK2 + RC v5 project is omitting the In-App Purchase Key from the RevenueCat dashboard. The SDK itself will not throw a startup error. The purchase sheet will appear normally. The transaction will complete on-device. Your app will briefly receive what looks like a valid CustomerInfo object — and then the entitlement will silently fail to persist.

Here's exactly what happens at each layer:

  1. Client (StoreKit 2): Product.purchase() succeeds. The SK2 transaction is signed by Apple as a JWS (JSON Web Signature) token and delivered to the app.
  2. RC SDK: The SDK picks up the JWS token and POSTs it to RevenueCat's backend for server-side validation.
  3. RC Backend (without the key): RevenueCat cannot verify the JWS signature because verifying Apple's SK2 transaction JWTs requires the In-App Purchase Key your account holds. The backend rejects or cannot properly record the transaction.
  4. Net result: The user paid. The client-side purchase callback may fire. But the transaction is never permanently recorded in RevenueCat, the entitlement is not activated in CustomerInfo, and the purchase disappears on the next app launch.

The official docs state it plainly: "When using Purchases v5.x+ (i.e., StoreKit 2), transactions will fail to be recorded without this key being set. This can result in users not accessing the purchases they are entitled to." Source

The key is generated in App Store Connect under Users and Access → Integrations → In-App Purchase. One key covers all apps in your App Store Connect account. After generating and downloading the .p8 file (you get one shot to download it), upload it to the RevenueCat dashboard under your app's In-app purchase key configuration tab alongside the Issuer ID. Source

Important: Adding this key to an existing app with live transactions will retroactively correct historical data — previously estimated pricing, currency, and country fields will be updated. Expect a temporary spike in your Scheduled Data Exports reports. Source


2. purchasesAreCompletedBy: Two Modes, Two Very Different Behaviors

The Default: .revenueCat

When you configure the SDK without specifying purchasesAreCompletedBy, RevenueCat defaults to completing (finishing) transactions on your behalf:

import RevenueCat

// Standard RC v5 setup — RC owns the full SK2 transaction lifecycle
Purchases.configure(
    with: Configuration.Builder(withAPIKey: "appl_xxxxxxxxxxxx")
        .build()
)

In this mode, RevenueCat internally listens for new SK2 transactions via Apple's Transaction.updates async stream. When a new transaction arrives, the SDK validates it against the RC backend, updates CustomerInfo, and then calls transaction.finish() on the SK2 transaction. You do not call SKPaymentQueue.default().add(observer:). You do not implement paymentQueue(_:updatedTransactions:). You do not call SKPaymentQueue.default().finishTransaction(_:) anywhere. All of that is SK1 machinery — it is not needed, and adding it alongside the RC v5 SDK will cause double-finish conflicts. Source

The Explicit Mode: .myApp(storeKitVersion:)

If you have your own purchase handling code and want RevenueCat to observe-only — recording transactions without finishing them — use PurchasesAreCompletedBy.myApp:

import RevenueCat

// Your app owns transaction finishing — RC only observes and records
Purchases.configure(
    with: Configuration.Builder(withAPIKey: "appl_xxxxxxxxxxxx")
        .with(purchasesAreCompletedBy: .myApp(storeKitVersion: .storeKit2))
        .build()
)

Setting .myApp(storeKitVersion: .storeKit2) tells the SDK explicitly that: - Your code is making purchases via SK2 APIs - Your code is responsible for calling transaction.finish() on each Transaction - RC will observe transactions via Transaction.updates to record them server-side, but will not finish them

If you forget to finish transactions yourself in this mode, SK2 will keep re-delivering unfinished transactions on every app launch until the user's StoreKit queue backs up. Purchases will appear to double-process. Source

Terminology note: In RC v4, this was called "Observer Mode." The v5 SDK deprecates observerMode: true and replaces it with the purchasesAreCompletedBy API. The old term still appears in some cross-platform SDK docs. Source

macOS Edge Case with .myApp

On macOS specifically, when your app completes SK2 purchases and RC is in .myApp mode, the SDK will not detect the new transaction until the user foregrounds the app after the purchase. To get immediate detection, call:

// After your own StoreKit 2 purchase completes on macOS:
try await Purchases.shared.recordPurchase(purchaseResult)

Source


3. Trusted Entitlements: Informational vs. Enforced — With Real Code

RevenueCat uses strong SSL, but the user controls their device. A determined attacker can configure a MiTM proxy to forge the RC backend's response and grant themselves entitlements without paying. Trusted Entitlements counters this by having the SDK and backend co-sign entitlement responses with a cryptographic signature your app can verify. Source

What's Enabled by Default in RC v5

As of iOS SDK v5.15.0+, Trusted Entitlements is enabled in Informational mode by default. You don't add any configuration to turn it on. The SDK attaches a verification result to every EntitlementInfo object it returns. Source

The VerificationResult enum has four cases:

Case Meaning
.verified Signature checked and valid — entitlement data is trustworthy
.notRequested Verification was disabled or cached data pre-dates feature enablement
.unverified Signature was present but failed the check — possible tampering
.failed Signature check could not be completed (network/crypto error)

The Entitlement Gate Pattern

A critical mistake developers make is checking only isActive without also inspecting the verification result. Here's the correct production gate:

func checkProAccess(customerInfo: CustomerInfo) -> AccessResult {
    guard let entitlement = customerInfo.entitlements["pro"] else {
        return .denied(reason: "Entitlement not found")
    }

    guard entitlement.isActive else {
        return .denied(reason: "Subscription not active")
    }

    // Check the cryptographic verification result
    switch customerInfo.entitlements.verification {
    case .verified:
        // Signature valid — grant access normally
        return .granted

    case .notRequested:
        // Verification disabled or stale cache — grant access but
        // consider calling Purchases.shared.invalidateCustomerInfoCache()
        // if you've just enabled Trusted Entitlements
        return .granted

    case .unverified:
        // Potential MiTM attack — do NOT grant access in high-value apps.
        // Log the event, alert the user, and deny premium features.
        logSecurityEvent("entitlement_verification_failed", userID: customerInfo.originalAppUserId)
        return .denied(reason: "Entitlement signature invalid")

    case .failed:
        // Crypto error — grant access gracefully to avoid false lockouts,
        // but schedule a re-check on next foreground
        return .grantedWithPendingVerification
    }
}

enum AccessResult {
    case granted
    case grantedWithPendingVerification
    case denied(reason: String)
}

Notice that customerInfo.entitlements.verification is checked at the collection level — it applies to the entire entitlements payload, not a single entitlement. This is intentional: if any part of the entitlements response was tampered with, the whole payload's signature is invalid. Source

When to Move to Enforced Mode

The default Informational mode logs verification errors and surfaces the result — but it does not block access automatically. Your app decides. For most apps, informational mode is the right starting point because false-positive lockouts (caused by .failed during transient network issues) would block paying customers from accessing content they've legitimately purchased.

To disable Trusted Entitlements entirely (not recommended for production):

Purchases.configure(
    with: Configuration.Builder(withAPIKey: "appl_xxxxxxxxxxxx")
        .with(entitlementVerificationMode: .disabled)
        .build()
)

To explicitly set informational mode (same as the v5.15.0+ default, useful for clarity):

Purchases.configure(
    with: Configuration.Builder(withAPIKey: "appl_xxxxxxxxxxxx")
        .with(entitlementVerificationMode: .informational)
        .build()
)

Source

Upgrade to enforced blocking in your app-level code (not an SDK mode, but a pattern) only after you've instrumented .unverified telemetry for a few releases and confirmed you see true attack patterns rather than noise. The SDK's informational mode gives you exactly the data you need to make that call.

Cache Transition Warning

If you've been shipping with EntitlementVerificationMode.disabled and then enable .informational, cached CustomerInfo on-device will have verification == .notRequested — because that data was fetched before verification existed. Grant access normally in this case, but consider calling:

Purchases.shared.invalidateCustomerInfoCache()

…so the next getCustomerInfo() fetches fresh, properly-signed data from the server. Source


4. Testing Environment Specifics: The iOS 18.4 Simulator Bug

Status as of iOS 18.4 / Xcode 16.x: The iOS 18.4, 18.4.1, and 18.5 simulators have a confirmed bug where StoreKit fails to load products when using the App Store Connect sandbox environment. Calls to StoreKit.products(for:) and SKProductsRequest return empty arrays. The RevenueCat SDK will report no available products or Offerings. This does not affect physical devices. Apple has resolved this in iOS 26+. Source

Do not use the App Store Connect sandbox when running on affected simulators. The workaround is to use a local StoreKit Configuration file, which bypasses the remote sandbox entirely and loads products from your local Xcode project.

Exact Workaround: Local StoreKit Configuration File

Step 1 — Create the configuration file:

In Xcode, go to File → New → File… and search for "StoreKit Configuration File." Check "Sync this file with an app in App Store Connect" to pre-populate it with your existing products, or define products locally. Save it in your project root and add it to your target. Source

Step 2 — Create a dedicated test scheme:

  • Click the scheme selector → Manage Schemes…
  • Select your current scheme → Duplicate
  • Name it something like MyApp (StoreKit Testing)

Step 3 — Attach the configuration file to the scheme:

  • Open the new scheme (Edit Scheme…)
  • Select the Run action → Options tab
  • Under StoreKit Configuration, select the .storekit file you created in Step 1
  • Click Close

Source

Now when you run using this scheme on any simulator (including iOS 18.4/18.5), StoreKit loads products from your local configuration file — no network request to App Store Connect sandbox is needed, and the bug is completely bypassed.

Important: Products defined in your StoreKit configuration file must also be configured in RevenueCat (Dashboard → Products). They don't need to exist in App Store Connect, but RevenueCat's backend needs to recognize the product ID to validate the simulated transaction. Source

Caveat: Local StoreKit testing does not generate real App Store receipts. Cancellation and refund events triggered via Xcode → Debug → StoreKit → Manage Transactions… will not appear in the RevenueCat dashboard, though the SDK will correctly reflect the removed entitlement on the next getCustomerInfo() call. Source

macOS caveat: Local StoreKit configuration files are currently incompatible with macOS simulator builds in the RC SDK — simulated transactions appear to go through but return a backend error. Test macOS IAP on physical hardware. Source


5. Production-Hardening Checklist — API-Specific

These are not generic best practices. Each item references a specific API or failure mode.

✅ Configure the SDK Before Any UI Is Shown

RC v5 fetches and caches CustomerInfo at configuration time. If you call Purchases.configure(with:) after your main view renders, users may briefly see the free tier before entitlements load. Configure in application(_:didFinishLaunchingWithOptions:) or your SwiftUI App.init().

@main
struct MyApp: App {
    init() {
        Purchases.logLevel = .debug // remove before App Store submission
        Purchases.configure(
            with: Configuration.Builder(withAPIKey: "appl_xxxxxxxxxxxx")
                .build()
        )
    }
    var body: some Scene { WindowGroup { ContentView() } }
}

Source

✅ Do Not Add an SKPaymentQueue Observer

With RevenueCat v5 and SK2, the SDK handles all transaction lifecycle events internally using Apple's Transaction.updates async listener. You do not need to call SKPaymentQueue.default().add(observer:) or implement SKPaymentTransactionObserver. Adding your own SKPaymentQueue observer alongside the RC v5 SDK creates a race condition where transactions may be finished twice or processed out of order. Source

✅ Subscribe to CustomerInfo Updates for Real-Time Entitlement Changes

Never rely solely on the purchase callback to gate content. Family Sharing grants, subscription modifications from another device, and renewals all arrive via the CustomerInfo delegate — not the purchase callback:

class SubscriptionManager: NSObject, PurchasesDelegate {
    func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) {
        let isProActive = customerInfo.entitlements["pro"]?.isActive == true
        let isVerified = customerInfo.entitlements.verification == .verified ||
                         customerInfo.entitlements.verification == .notRequested
        DispatchQueue.main.async {
            self.updateUI(proUnlocked: isProActive && isVerified)
        }
    }
}

Source

✅ Call getCustomerInfo() at Paywall Entry Points

The SDK caches CustomerInfo and refreshes it when the app becomes active. However, RC explicitly recommends calling getCustomerInfo() any time a user attempts to access premium content, since the cache can be up to 5 minutes stale:

func showPremiumContent() async {
    do {
        let customerInfo = try await Purchases.shared.customerInfo()
        let entitlement = customerInfo.entitlements["pro"]

        if entitlement?.isActive == true,
           customerInfo.entitlements.verification != .unverified {
            // Grant access
        } else {
            // Show paywall
        }
    } catch {
        // Handle error — fail open or closed depending on your policy
    }
}

Source

✅ Handle the .unverified Case Explicitly in High-Security Contexts

The v5 SDK does not automatically block .unverified entitlements — it's informational by default. In high-value apps (financial content, professional tools, etc.), you should treat .unverified as a hard denial and instrument it as a security event:

if customerInfo.entitlements.verification == .unverified {
    // Log to your analytics/monitoring system
    // Do NOT grant premium access
    // Show the user a message explaining why
}

Source

✅ Validate Credentials in RevenueCat Dashboard Before Shipping

The RevenueCat dashboard has a built-in credential validator. After uploading your In-App Purchase Key, click Validate Credentials. You should see a "Valid credentials" badge with all permission checkboxes green. If any show red — particularly the In-App Purchase Key scope — fix them before submitting to the App Store. Purchases will fail silently in production if credentials are misconfigured. Source

✅ Never Ship With a Test Store API Key

RevenueCat's Test Store uses a different API key from your real App Store app. It is critical that you never include a Test Store API key in a production App Store build. Use build configuration flags to switch keys:

#if DEBUG
let apiKey = "rcb_test_xxxxxxxxxx" // Test Store key
#else
let apiKey = "appl_xxxxxxxxxxxx"   // Real App Store key
#endif
Purchases.configure(with: Configuration.Builder(withAPIKey: apiKey).build())

Source


Putting It Together: Annotated Setup for a Production SK2 App

import RevenueCat

@main
struct ProApp: App {

    init() {
        // 1. Enable verbose logs in debug builds only
        #if DEBUG
        Purchases.logLevel = .debug
        #endif

        // 2. Configure with the full SK2 + Trusted Entitlements defaults.
        //    RC v5 + informational entitlement verification is the default —
        //    no extra configuration needed unless you want to change it.
        //    IMPORTANT: In-App Purchase Key must be uploaded to RC dashboard.
        Purchases.configure(
            with: Configuration.Builder(withAPIKey: productionAPIKey)
                // Uncomment only if you have your own StoreKit purchase code:
                // .with(purchasesAreCompletedBy: .myApp(storeKitVersion: .storeKit2))
                .build()
        )
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

// Entitlement gate — use this everywhere you check subscription status
func isProEntitlementValid(in customerInfo: CustomerInfo) -> Bool {
    guard customerInfo.entitlements["pro"]?.isActive == true else { return false }

    switch customerInfo.entitlements.verification {
    case .verified, .notRequested:
        return true          // Trust the response
    case .unverified:
        return false         // Treat as denied — possible MiTM
    case .failed:
        return true          // Fail open to avoid false lockouts; re-check soon
    }
}

Key Takeaways

Risk Root Cause Fix
Purchases recorded client-side but not in RC Missing In-App Purchase Key — backend can't verify JWS signatures Upload .p8 key + Issuer ID to RC dashboard
SK2 transactions stuck unfinished .myApp mode with no call to transaction.finish() Either switch to .revenueCat or ensure your code finishes every SK2 transaction
Products return empty in iOS 18.4 simulator Sandbox network bug in affected simulators Use a local StoreKit Configuration file via a dedicated Xcode scheme
MiTM entitlement bypass Checking only isActive, ignoring verification Check customerInfo.entitlements.verification != .unverified before granting access
Wrong SKPaymentQueue observer Adding SK1 observer alongside SK2 SDK Remove SKPaymentQueue.default().add(observer:) — RC handles Transaction.updates

Sources

  1. In-App Purchase Key Configuration — Required setup for SK2; what breaks without it
  2. iOS Native 4.x to 5.x MigrationpurchasesAreCompletedBy, SK2 defaults, Trusted Entitlements in v5
  3. Trusted Entitlements — VerificationResult enum, informational vs disabled mode, cache transition
  4. iOS 18.4 Simulator Fails to Load Products — Bug details, affected versions, workarounds
  5. Apple App Store & TestFlight Sandbox Testing — StoreKit Configuration file setup, Xcode scheme editor steps
  6. Using the SDK with Your Own IAP Code (Observer Mode)purchasesAreCompletedBy with .myApp, syncPurchases()
  7. Getting Subscription Status (CustomerInfo)getCustomerInfo(), caching behavior, delegate updates
  8. Configuring the SDKConfiguration.Builder, Test Store vs production API keys
  9. SDK QuickstartcustomerInfo.entitlements["pro"]?.isActive pattern
  10. StoreKit Known Issues Index — Full list of tracked SK issues
  11. iOS 18.3.1–18.5 Canceled State After Successful Purchase — Receipt renewal email prompt bug; CustomerInfo delegate as mitigation
  12. iOS & Apple Platforms Installation — In-App Purchase capability setup, SPM integration
  13. App-Specific Shared Secret vs In-App Purchase Key — SK1 vs SK2 credential requirements