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:
- Issues fetching products — products aren't attached to the submission or aren't in "Ready to Submit" state
- Errors during the purchase flow — a
STORE_PROBLEMor auth error surfaces during the review session - Content not unlocked after purchase — entitlements aren't checked correctly after a successful transaction
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-android9.16.0+ (orreact-native-purchases9.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_abbreviated — not 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."
2d. Include Terms & Privacy Policy Links
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
- Apple App Store Rejections — RevenueCat
- Getting Your Paywall Approved Through App Review — RevenueCat
- App Subscription Launch Checklist — RevenueCat
- Error Handling — RevenueCat
- Restoring Purchases — RevenueCat
- Using the SDK with Your Own IAP Code (purchasesAreCompletedBy) — RevenueCat
- iOS Native 4.x to 5.x Migration (Observer Mode → PurchasesAreCompletedBy) — RevenueCat
- iOS 18.0–18.3.2: Purchases May Fail Due to StoreKit Daemon Connection Issue — RevenueCat
- iOS 18.3.1–18.5: Canceled State After Successful Purchase — RevenueCat
- StoreKit Known Issues Index — RevenueCat
- SDK Quickstart — RevenueCat