You've followed the quickstart, your app builds, and sandbox purchases go through. Now comes the question that bites developers in production: is this implementation actually bullet-proof?

Subscriptions fail in specific, painful ways — a simulator that silently returns no products, a Man-in-the-Middle attack that tricks your entitlement gate, a missing .p8 key that causes purchases to silently fail for real users, or the wrong purchasesAreCompletedBy mode that causes double-finishing of transactions. This guide addresses every one of those operational gaps.

Assumptions: Swift, iOS 15+, RevenueCat iOS SDK v5.x (StoreKit 2 is on by default), and you've already completed basic SDK setup.


1. Testing Strategy: Three Environments, Three Jobs

The single most common mistake is treating all testing environments as interchangeable. They are not. Each solves a distinct problem.

The Three-Tier Testing Stack

Environment Best For Critical Limitation
Xcode StoreKit Configuration File Simulator, rapid iteration, offline development Not connected to RevenueCat's backend; can't test server-side validation
Apple Sandbox Full end-to-end integration testing before launch Metadata (prices, names) is often inaccurate; simulator broken on iOS 18.4–18.5
Production Real users, real money No way to reverse mistakes

RevenueCat's own docs describe this cleanly: start with fast local testing, graduate to sandbox before launch, and deploy to production only after both pass. Source

Setting Up a StoreKit Configuration File (Simulator Testing)

A StoreKit Configuration file lets you define products locally in Xcode — no App Store Connect setup required — and run them in the iOS Simulator. This is ideal for building UI, testing paywall logic, and iterating on purchase flows without any network dependency.

Steps: 1. Go to File > New > File… and select StoreKit Configuration File. 2. Enable "Sync this file with an app in App Store Connect" if you want to auto-import your already-configured products. 3. Add the file to your target and create a duplicate scheme with this configuration file selected under Run > Options > StoreKit Configuration.

⚠️ StoreKit testing only works when running directly from Xcode. CLI tools like flutter run or command-line xcodebuild won't pick up your scheme's StoreKit Configuration file. Source

⚠️ Product IDs must still be configured in RevenueCat. Even though the products don't need to exist in App Store Connect, they do need to exist in your RevenueCat dashboard so the SDK can map them to entitlements. Source

⚠️ macOS is incompatible with StoreKit Configuration file testing when using RevenueCat. Purchases will appear to succeed but you'll receive a backend error. Test on device or simulator for iOS targets. Source

The iOS 18.4–18.5 Simulator Bug and Workaround

If you're targeting the iOS 18.4 or 18.5 simulators and using the Apple Sandbox environment (not a StoreKit Config file), StoreKit simply returns empty product arrays. StoreKit.products(for:) and SKProductsRequest both fail silently. Source

Workaround options (pick one): - Option A (Recommended for this stage): Switch to a StoreKit Configuration file. This bypasses the sandbox entirely and avoids the bug. - Option B: Test on a physical device with a sandbox Apple ID — this is always the authoritative test environment anyway. - Option C: Use the iOS 18.3 or iOS 26+ simulator, where the bug is absent. Apple resolved it starting in iOS 26.

Apple's Feedback ID for this issue is FB17105187. The bug does not affect App Store Review (which uses physical devices), and it does not affect production users. Source

When to Graduate to Apple Sandbox

Before any production submission, switch to your platform-specific RevenueCat API key (not a Test Store key) and test the full purchase flow on a physical device using a sandbox Apple ID. This catches real-world issues that StoreKit Config files can never surface: server-side validation timing, receipt processing, subscription renewal behavior, and billing grace periods. Source


2. Trusted Entitlements Deep Dive: Building an Entitlement Gate That Actually Blocks Fraud

Why SSL Alone Isn't Enough

RevenueCat uses strong SSL for all communications. But SSL only protects the wire — it doesn't protect against a determined attacker who controls the client device and configures it for a Man-in-the-Middle (MiTM) attack to inject fake entitlement responses. Source

Trusted Entitlements solves this. When enabled, the SDK and RevenueCat's backend exchange cryptographically signed entitlement data. The SDK then exposes a VerificationResult on every EntitlementInfo object.

Default Behavior in SDK v5

In iOS SDK 5.15.0+, Trusted Entitlements is enabled by default in informational mode. This means: - The SDK always provides verification data. - Verification errors are logged and forwarded to Purchases.errorHandler. - Nothing is automatically blocked. Granting or denying access is entirely your responsibility. Source

For iOS SDK 4.25.0–5.14.x, it is disabled by default and must be explicitly enabled.

Configuring the Verification Mode

import RevenueCat

// Opt into informational mode (default in v5.15.0+, explicit for older versions)
Purchases.configure(
    with: Configuration.Builder(withAPIKey: "your_api_key")
        .with(entitlementVerificationMode: .informational)
        .build()
)

// To disable entirely (not recommended for production)
// .with(entitlementVerificationMode: .disabled)

Reading VerificationResult

Every EntitlementInfo object carries a .verification property of type VerificationResult. The possible values are:

  • .verified — The response signature is valid. Trust this entitlement.
  • .notRequested — Verification was disabled at SDK configuration time.
  • .failed — The cryptographic verification failed. This is the signal you act on.

⚠️ There is also a .verifiedOnDevice case (internal to some SDK paths) but the actionable states for your gate are .verified vs .failed/.notRequested.

Building the Entitlement Gate

Here's the pattern that actually blocks fraudulent access. Note that the SDK never blocks automatically — you must implement this check:

func userHasProAccess(customerInfo: CustomerInfo) -> Bool {
    guard let entitlement = customerInfo.entitlements["pro"],
          entitlement.isActive else {
        return false
    }

    // Check the cryptographic verification result
    switch entitlement.verification {
    case .verified:
        // Signature checks out — safe to grant access
        return true

    case .failed:
        // Signature verification failed — potential MiTM attack
        // Log this event for monitoring, then deny access
        print("⚠️ Entitlement verification failed — denying access")
        reportToAnalytics(event: "entitlement_verification_failed")
        return false

    case .notRequested:
        // Verification was disabled. Decide based on your risk tolerance.
        // For maximum security, deny. For backwards compatibility, allow.
        return false

    @unknown default:
        return false
    }
}

The Cache Transition Gotcha

If you upgrade an existing app from EntitlementVerificationMode.disabled to .informational, cached CustomerInfo was fetched without verification. The SDK will serve that cache and return .notRequested until a fresh network fetch occurs. If you need an immediate clean state, call:

Purchases.shared.invalidateCustomerInfoCache()

Then re-fetch CustomerInfo. Source


3. purchasesAreCompletedBy: Observer Mode's Replacement and Its SK2 Gotchas

The Rename

RevenueCat v5 retired the term "Observer Mode" and replaced it with the purchasesAreCompletedBy configuration parameter. The semantics are the same — you're telling the SDK who is responsible for calling Transaction.finish() on StoreKit transactions — but the API is now explicit. Source

The Two Modes

.revenueCat (default): The SDK manages the full purchase lifecycle — it initiates, validates, and finishes transactions. Use this unless you have a specific reason not to.

Purchases.configure(
    with: Configuration.Builder(withAPIKey: "your_api_key")
        .with(purchasesAreCompletedBy: .revenueCat, storeKitVersion: .storeKit2)
        .build()
)

.myApp(storeKitVersion:) (formerly observer mode): Your app code calls StoreKit directly and is responsible for finishing transactions. RevenueCat passively observes and records purchases. Use this only if you have pre-existing IAP code you're migrating alongside RevenueCat.

Purchases.configure(
    with: Configuration.Builder(withAPIKey: "your_api_key")
        .with(purchasesAreCompletedBy: .myApp(storeKitVersion: .storeKit2), storeKitVersion: .storeKit2)
        .build()
)

Source

The Critical SK2 Double-Finish Gotcha

StoreKit 2 introduces a Transaction.updates async sequence that automatically delivers unfinished transactions. If both your app code and RevenueCat are listening to this sequence (and both call transaction.finish()), you'll get double-finishing. While SK2 is designed to be idempotent for finishing, double-finishing can cause:

  • Revenue events firing twice in 3rd-party analytics SDKs (Mixpanel, Segment, Firebase, etc.)
  • Race conditions in your own purchase completion handlers

Rule: If you use .revenueCat, do not also set up your own Transaction.updates listener. If you use .myApp, do not let RevenueCat also try to finish transactions by mixing modes. Source

The macOS Observer Mode Gap

On macOS, when using .myApp with StoreKit 2, RevenueCat doesn't detect purchases until the user foregrounds the app after the transaction completes. If you need immediate detection, call Purchases.shared.recordPurchase(_:) explicitly:

// After your app makes a SK2 purchase
let purchaseResult = try await product.purchase()
switch purchaseResult {
case .success(let verificationResult):
    // Explicitly record with RevenueCat for immediate detection
    let customerInfo = try await Purchases.shared.recordPurchase(verificationResult)
    unlockContent(for: customerInfo)
case .pending, .userCancelled:
    break
@unknown default:
    break
}

Source


4. The In-App Purchase Key: What Breaks If You Skip It

This is the silent killer for SK2 + RC v5 apps. The configuration is easy to miss because it's a dashboard step, not a code step — and the failure mode is that purchases appear to succeed on the client but are never recorded by RevenueCat.

What the Key Does

The In-App Purchase Key (a .p8 file from App Store Connect) allows RevenueCat's backend to: - Validate SK2 transactions server-side - Resolve accurate country, currency, and price data for each purchase - Enable Subscription Offers (intro offers, promotional offers) - Support customer lookup by Order ID

Without it, when using SDK v5+, transactions will fail to be recorded. Users lose access to content they paid for. Source

SDK Versions That Require This Key

This is required for all v5+ SDK flavors: - iOS: 5.0.0+ - React Native: 8.0.0+ - Flutter: 7.0.0+ - Capacitor: 9.0.0+ - Unity: 7.0.0+

Source

Setup Steps

1. Generate the key in App Store Connect: Navigate to Users and Access → Integrations → In-App Purchase. Click Generate In-App Purchase Key. You get one chance to download the .p8 file — store it securely. Source

2. Upload to RevenueCat: In your RevenueCat dashboard, go to Apps & Providers → [Your App] → In-App Purchase Key Configuration. Upload the .p8 file. Source

3. Add the Issuer ID: The Issuer ID is on the same App Store Connect page (Users and Access → Integrations → In-App Purchase). If it's missing, you need to first generate an App Store Connect API key — this forces the Issuer ID to appear. Source

4. Validate: The RevenueCat dashboard will run a credential validation check. Look for a "Valid credentials" confirmation with all permissions checked.

💡 One key per App Store Connect account. You can reuse the same In-App Purchase Key across all RevenueCat projects that belong to the same App Store Connect account. Source


5. Production-Hardening Checklist

With all the above in place, run through this checklist before shipping.

SDK Configuration Checklist

import RevenueCat

// Full production-hardened SDK configuration
func configureRevenueCat() {
    Purchases.logLevel = .warn // Reduce noise in production; use .debug during dev

    Purchases.configure(
        with: Configuration.Builder(withAPIKey: Constants.revenueCatAPIKey)
            // Entitlement verification: informational is default in v5.15.0+
            // Explicitly set it so your intent is clear in code review
            .with(entitlementVerificationMode: .informational)
            // Default: RevenueCat completes purchases. Only change if you have
            // existing IAP code managing transactions.
            .with(purchasesAreCompletedBy: .revenueCat, storeKitVersion: .storeKit2)
            .build()
    )
}

Pre-Launch Checklist

Testing: - [ ] Tested purchase flow with Xcode StoreKit Configuration file on the simulator - [ ] Tested end-to-end on a physical device using Apple Sandbox + sandbox Apple ID - [ ] Tested on iOS 18.3 or 18.6+ simulator (avoid 18.4–18.5 due to the known bug) - [ ] Verified Sandbox data appears correctly in RevenueCat dashboard (toggle sandbox view on)

Credentials: - [ ] In-App Purchase Key (.p8) uploaded to RevenueCat dashboard - [ ] Issuer ID entered and saved - [ ] Credential validation shows "Valid credentials" in the dashboard

Entitlement Security: - [ ] entitlement.verification is checked in all entitlement gates (not just entitlement.isActive) - [ ] .failed verification result is logged and denies access - [ ] invalidateCustomerInfoCache() called if upgrading from disabled to informational mode

Transaction Handling: - [ ] If using .revenueCat mode: no custom Transaction.updates listener in your code - [ ] If using .myApp mode: storeKitVersion explicitly set; recordPurchase() called on macOS - [ ] Third-party analytics SDKs (Firebase, Mixpanel, etc.) are NOT auto-tracking SK2 purchases — use RevenueCat integrations instead Source

API Keys: - [ ] Production/Release builds use the platform-specific API key (iOS), not a Test Store API key - [ ] Using build configurations or environment variables to switch keys per build type Source

Handling the Purchases.errorHandler in Production

Trusted Entitlements always forwards verification errors to Purchases.errorHandler. Wire this up to your error-monitoring pipeline:

Purchases.errorHandler = { error in
    // Send to Sentry, Datadog, etc.
    ErrorMonitor.shared.capture(error, context: ["source": "revenuecat"])
}

This gives you observability into verification failures in production without needing to add per-call error handling everywhere.


Putting It All Together

A production-grade SK2 + RevenueCat app has:

  1. Three-tier testing: StoreKit Config file for simulator speed, Apple Sandbox for integration truth, physical device as the final arbiter.
  2. Active entitlement verification: Not just checking isActive, but reading and acting on entitlement.verification to deny MiTM-tampered access.
  3. Explicit purchasesAreCompletedBy configuration: Clear declaration of who finishes transactions, preventing double-finish races with SK2's async delivery.
  4. In-App Purchase Key configured on day one: The dashboard prerequisite that silently breaks transaction recording if skipped.
  5. Correct API keys per environment: Test Store key for development, platform-specific key for release — enforced at build time, not manually.

The difference between a subscription implementation that "works in testing" and one that "works reliably in production for real users" is exactly this operational depth. None of these steps are complex individually, but each one is easy to skip — and each one has a production failure mode.


Sources