You've spent weeks building your first subscription app. Sandbox purchases work perfectly. TestFlight testers are loving it. You hit Submit for Review — and three days later you get a rejection email. For many indie developers, this is the first IAP submission, and it stings.

The good news: the most common rejection reasons are predictable, reproducible, and fixable before you submit. This checklist walks through every major pitfall — with real code — so your app ships on the first try.


Why IAPs Get Rejected (The Real Stats)

According to Apple's App Review Center, over 40% of all rejections fall under Guideline 2.1 (App Completeness) — meaning something didn't work during review. For IAP-specific rejections, the three hotspots RevenueCat consistently sees are:

  1. Issues fetching products — products aren't attached to the submission or aren't in "Ready to Submit" state
  2. Errors during the purchase flow — a STORE_PROBLEM or auth error surfaces during the review session
  3. Content not unlocked after purchase — entitlements aren't checked correctly after a successful transaction

Source

Let's fix all of them, plus the paywall compliance issues that trip up first-timers on both the App Store and Google Play.


Item 1 — Implement a Visible "Restore Purchases" Button

This is Apple's most-enforced IAP rule. Apple requires a restore mechanism in every app that sells non-consumable purchases or auto-renewable subscriptions. Miss it and you'll get rejected under Guideline 3.1.1.

RevenueCat's restorePurchases() method handles this with a single call. The key rule: never call it programmatically on app launch — it can trigger OS-level Apple ID sign-in sheets. Wire it to a button tap only. Source

Swift (iOS)

// In your paywall or settings screen
Button("Restore Purchases") {
    Task {
        do {
            let customerInfo = try await Purchases.shared.restorePurchases()
            if customerInfo.entitlements["premium"]?.isActive == true {
                // Unlock content
            } else {
                // Show "no purchases found" message
            }
        } catch {
            print("Restore failed: \(error.localizedDescription)")
        }
    }
}

Kotlin (Android)

// In your paywall Fragment or Composable
restoreButton.setOnClickListener {
    Purchases.sharedInstance.restorePurchasesWith(
        onError = { error ->
            Log.e("RC", "Restore failed: ${error.message}")
        },
        onSuccess = { customerInfo ->
            val isPremium = customerInfo.entitlements["premium"]?.isActive == true
            if (isPremium) unlockContent() else showNoPurchasesDialog()
        }
    )
}

React Native

import Purchases from 'react-native-purchases';

const handleRestore = async () => {
  try {
    const customerInfo = await Purchases.restorePurchases();
    const isPremium = customerInfo.entitlements.active['premium'] !== undefined;
    if (isPremium) {
      unlockContent();
    } else {
      Alert.alert('No Active Subscription', 'We couldn\'t find an active subscription for this account.');
    }
  } catch (e) {
    console.error('Restore error:', e);
  }
};

⚠️ Android gotcha: If you use anonymous users AND sell consumable one-time products, Google's Billing Client 8+ removed the ability to query consumed purchases. Upgrade to purchases-android 9.16.0+ (or react-native-purchases 9.6.12+) for the fix. Source


Item 2 — Make Your Paywall Disclosure-Compliant

Paywalls are the single most scrutinized screen in app review. Both Apple and Google want reviewers to see clear pricing expectations before any money changes hands. These are the four disclosure requirements that cause rejections: Source

2a. Show the Full Billed Amount

Showing $4.16/mo is fine as a value comparison, but the actual charge ($49.99/yr) must also appear. Using RevenueCat Paywalls, use product.price_per_period or product.price_per_period_abbreviatednot product.price alone or product.price_per_month in isolation.

2b. Disclose Introductory Offers Prominently

If you offer a free trial or discounted first period, that has to be the first thing a user sees — because it's what they're about to be charged (or not charged) for immediately. Use product.offer_price and product.offer_period in your CTA label:

"Start 7-Day Free Trial — then {{ product.price_per_period }}"

India has particularly strict rules about offer term disclosure. If your app is offered there, pay extra attention to this. Source

2c. Add "Cancel Anytime" Language

Reviewers will sometimes look for explicit cancel instructions. A simple line underneath your CTA is enough:

"Try free for 7 days, then $9.99/mo. Cancel anytime in Settings."

While the App Store no longer requires these directly on the paywall, reviewers will reject an app if these links are hard to find. The paywall is the expected place. Add them as footer links. RevenueCat's built-in Paywall components auto-localize these. Source


Item 3 — Close Your Sandbox Testing Gaps

The most frustrating rejection scenario: everything worked in your sandbox, but the reviewer couldn't complete a purchase. Here's why this happens and how to prevent it.

3a. Switch from Test Store API Key to Platform API Key

RevenueCat ships with a Test Store that requires zero App Store or Google Play setup. It's great for development. But a Test Store API key will crash your production app. Source

Before submission: 1. Go to RevenueCat Dashboard → Project Settings → API Keys 2. Copy your iOS or Android platform-specific key 3. Use it in your release build — never the Test Store key

// ❌ This will crash in production
Purchases.configure(withAPIKey: "rcb_test_store_XXXX")

// ✅ Use your platform-specific key
#if DEBUG
Purchases.configure(withAPIKey: ProcessInfo.processInfo.environment["RC_API_KEY_DEBUG"] ?? "")
#else
Purchases.configure(withAPIKey: "appl_XXXXXXXXXXXXXXXX")
#endif

3b. Test Against Real Store Sandbox BEFORE Submitting

Test Store validates logic; real-store sandbox validates your actual integration. Before submitting, run at least one end-to-end purchase in Apple Sandbox or Google Play test tracks with your platform API key. This will catch misconfigurations in your App Store Connect shared secret, your Google Play service credentials, or your entitlement mappings before a reviewer finds them. Source

3c. Mark Products "Ready to Submit" in App Store Connect

For your first version containing IAPs: products won't automatically be included in your binary submission. On the version page in App Store Connect, you'll see a section to attach in-app purchases. Every product must have a status of "Ready to Submit" before it will appear for a reviewer. Source

3d. Sign Banking and Tax Agreements

Products won't load in any environment if your paid app agreements haven't been accepted and banking information hasn't been entered in App Store Connect / Google Play Console. Do this before any testing. Source


Item 4 — Handle STORE_PROBLEM Gracefully

STORE_PROBLEM is the most common RevenueCat error that triggers unexplained rejections. It fires when the underlying store (Apple Sandbox, in particular) is unavailable. Source

The error maps to Apple's SKErrorUnknown, SKErrorCloudServiceNetworkConnectionFailed, or similar codes. It also fires on Android for BillingClient.SERVICE_TIMEOUT or quota exceeded errors.

Your app should handle this with a user-friendly, retry-able message rather than a hard crash or silent failure:

Swift

do {
    let result = try await Purchases.shared.purchase(package: package)
    unlockContent(for: result.customerInfo)
} catch let error as RevenueCat.ErrorCode {
    switch error {
    case .storeProblemError:
        showAlert(
            title: "Store Unavailable",
            message: "There was a temporary issue with the App Store. Please try again in a moment."
        )
    case .purchaseCancelledError:
        break // User cancelled — no alert needed
    default:
        showAlert(title: "Purchase Failed", message: error.localizedDescription)
    }
} catch {
    showAlert(title: "Purchase Failed", message: error.localizedDescription)
}

Kotlin

Purchases.sharedInstance.purchaseWith(
    purchaseParams = PurchaseParams.Builder(activity, packageToPurchase).build(),
    onError = { error, userCancelled ->
        if (userCancelled) return@purchaseWith
        when (error.code) {
            PurchasesErrorCode.StoreProblemError -> showToast(
                "The Play Store is temporarily unavailable. Please try again."
            )
            else -> showToast("Purchase failed: ${error.message}")
        }
    },
    onSuccess = { _, customerInfo ->
        unlockContent(customerInfo)
    }
)

RevenueCat will automatically retry purchase failures so no data is lost. The user-facing message just needs to not be a crash. Source


Item 5 — Audit purchasesAreCompletedBy if You Migrated from Observer Mode

This is the silent killer for apps that previously had their own StoreKit code and are adding RevenueCat. In SDK v5+, "Observer Mode" was renamed to purchasesAreCompletedBy. If you're migrating from SDK v4 and had observerMode: true, you must now explicitly pass .myApp and declare which StoreKit version you use: Source

// ❌ Old v4 pattern (no longer valid in v5)
// Purchases.configure(withAPIKey: apiKey, appUserID: nil, observerMode: true)

// ✅ Correct v5 pattern when your app handles its own purchases
let config = Purchases.Configuration.builder(withAPIKey: apiKey)
    .with(purchasesAreCompletedBy: .myApp, storeKitVersion: .storeKit2)
    .build()
Purchases.configure(with: config)

If you're using RevenueCat to both make and observe purchases (the default), leave purchasesAreCompletedBy at its default (.revenueCat). Setting it incorrectly to .myApp means RevenueCat will never finish transactions — content won't unlock, and the reviewer will see a broken purchase flow.

Also critical: SDK v5+ requires an In-App Purchase Key configured in the RevenueCat dashboard for StoreKit 2 support. Without it, all purchases will fail. Source


Item 6 — Know the iOS 18 StoreKit Bugs That Affected App Review

Several iOS 18 StoreKit bugs caused false rejections or confusing purchase states. These have been resolved by Apple, but you should know about them for historical context and to understand error logs you may still see:

STORE_PROBLEM with NSCocoaErrorDomain Code=4097 (iOS 18.0–18.3.2)

This bug caused the StoreKit daemon connection to fail intermittently, resulting in a STORE_PROBLEM error with the message connection to service with pid X named com.apple.storekitd. No money was moved. Source

Status: ✅ Resolved in iOS 18.4. App reviewers should no longer be on affected OS versions.

Canceled State After Successful Purchase (iOS 18.3.1–18.5)

An even nastier bug: StoreKit returned a "canceled" state after a successful charge when a Receipt Renewal email prompt appeared mid-flow. The RevenueCat SDK reported the purchase as canceled, but it went through correctly seconds later via the CustomerInfo delegate. Source

Status: ✅ Resolved by Apple. The mitigation is still best practice: always monitor CustomerInfo updates via a listener instead of relying solely on the purchase() callback:

// Best practice: listen for CustomerInfo changes as a backup signal
class AppDelegate: UIResponder, UIApplicationDelegate, PurchasesDelegate {
    func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) {
        // Always called when entitlement status changes, even if purchase() callback fires incorrectly
        updateUI(for: customerInfo)
    }
}

The Pre-Submission Checklist at a Glance

Run through this before every submission that touches purchases:

# Check Platform
"Restore Purchases" button is visible and wired to restorePurchases() iOS + Android
Paywall shows full billed amount (not just monthly equivalent) iOS + Android
Introductory offer price and duration are disclosed in the CTA iOS + Android
"Cancel anytime" language is present iOS + Android
Terms of Service and Privacy Policy links are accessible iOS + Android
Platform-specific API key replaces Test Store key in release build iOS + Android
End-to-end purchase tested in Apple Sandbox / Google Play testing iOS + Android
All IAP products are "Ready to Submit" in App Store Connect iOS
IAPs are attached to the app version submission (first-time IAP release) iOS
Banking/tax agreements signed in App Store Connect & Google Play Console iOS + Android
STORE_PROBLEM error shows a user-friendly retry message, not a crash iOS + Android
purchasesAreCompletedBy is set correctly (.revenueCat unless migrating) iOS
In-App Purchase Key configured in RevenueCat dashboard (SDK v5+ / SK2) iOS
CustomerInfo delegate listener active as a backup to purchase() callback iOS
All products mapped to Entitlements in RevenueCat dashboard iOS + Android

A Note on Re-Submissions

If you've verified everything above and still get rejected for a purchase issue, Apple's sandbox environment itself may have been down during the review session. STORE_PROBLEM is the tell. RevenueCat tracks major sandbox outages at status.revenuecat.com. If there was a known outage during your review window, mention it in your response to the reviewer — some developers have needed to re-submit the same build up to a dozen times before approval during major outages. Source


Sources