โ† All articles

How to Restore Purchases in a SwiftUI App (and Pass App Review)

Every iOS subscription app needs a working Restore Purchases button to pass review. How to restore purchases in SwiftUI with StoreKit 2 and RevenueCat.

How to Restore Purchases in a SwiftUI App (and Pass App Review)

Every iOS app that sells subscriptions or non-consumable purchases must give the customer a way to restore purchases, and App Review will reject you if it is missing or broken. With StoreKit 2 the actual restore is one call, AppStore.sync(), followed by re-reading the customer's current entitlements. The button exists so a customer on a new device, a reinstall, or a fresh sign-in can get back what they already paid for without paying again.

This post shows how to implement restore purchases in a SwiftUI app with both StoreKit 2 and RevenueCat, where the button belongs, and the specific mistakes that get apps rejected under App Store Review guideline 3.1.1.

On this page

Why restore is required, and when it runs

Purchases are tied to the customer's Apple ID, not to your app's install or your own account system. So when someone reinstalls your app, gets a new phone, or signs in on a second device, your app has no local record of what they own until it asks the App Store. Restore is how it asks.

There is a nuance worth understanding up front. In StoreKit 2, Transaction.currentEntitlements already reflects everything the Apple ID currently owns, and it syncs automatically, so most of the time your app knows about a purchase without any explicit "restore." You still need a restore button for two reasons: App Review guideline 3.1.1 requires one for apps with non-consumable or auto-renewable purchases, and it is the escape hatch when the automatic sync has not caught up (a fresh install before the first sync, for example). Restore forces that sync on demand. For the broader picture of how purchases flow, see how an iOS subscription works end to end.

Restoring with StoreKit 2

Restore is two steps: trigger a sync with the App Store, then re-read entitlements. Only call AppStore.sync() from an explicit user action, because it can present an App Store sign-in prompt:

import StoreKit
 
func restorePurchases() async throws {
    // Forces a sync with the App Store. Call this ONLY from a "Restore
    // Purchases" tap: it may prompt the customer to sign in to the App Store.
    try await AppStore.sync()
    await refreshEntitlements()
}
 
/// The source of truth for what the Apple ID currently owns.
func refreshEntitlements() async {
    var owned: Set<String> = []
    for await result in Transaction.currentEntitlements {
        if case .verified(let transaction) = result {
            owned.insert(transaction.productID)
        }
    }
    // Update your access state from `owned` here.
}

Call refreshEntitlements() on launch (no sign-in prompt, no sync) so returning customers are recognized automatically, and reserve AppStore.sync() for the button. That split is the whole pattern: automatic recognition for the common case, an explicit sync for the button App Review needs to see.

Restoring with RevenueCat

RevenueCat wraps the same mechanism in one call that returns updated CustomerInfo:

import RevenueCat
 
func restore() async throws {
    let info = try await Purchases.shared.restorePurchases()
    let hasPro = info.entitlements["pro"]?.isActive == true
    // Update your UI from `hasPro`.
}

RevenueCat's prebuilt paywall (PaywallView from RevenueCatUI) already includes a restore button that calls this for you, so if you use it you get a compliant restore control without building one. The underlying setup is covered in adding subscriptions with RevenueCat. One RevenueCat-specific note: restore associates the purchases with the current RevenueCat app user ID, so if you use custom (non-anonymous) user IDs, understand how restore interacts with account transfers before you ship.

Where the button goes and what it says

The restore control has to be findable, because a reviewer will look for it and so will a customer who just reinstalled:

  • Put a "Restore Purchases" button on the paywall itself, and also somewhere persistent like Settings, so it is reachable when the paywall is not showing.
  • Label it plainly. "Restore Purchases" is the expected wording. Do not hide it behind an icon or bury it in a menu.
  • Show clear feedback for all three outcomes: purchases found and restored, nothing to restore, and an error or cancelled sign-in. A silent button reads as broken to both customers and reviewers.
Button("Restore Purchases") {
    Task {
        do {
            try await restorePurchases()
            // Then reflect the restored state (dismiss paywall, unlock, etc.)
        } catch {
            // Show "Couldn't restore. Please try again."
        }
    }
}

The mistakes that fail App Review

Restore is a recurring rejection theme under guideline 3.1.1. The usual causes:

  • No restore button at all. The most common one. If you sell non-consumables or subscriptions, a restore mechanism is mandatory, not optional.
  • A restore button that does nothing visible. If tapping it gives no confirmation, reviewers treat it as non-functional.
  • Gating restore behind your own account or login. Purchases belong to the Apple ID, so restore must work without forcing the customer to create or sign in to your account first.
  • Re-charging on restore. Restore must return existing entitlements, never start a new purchase. Calling a purchase API from the restore button is a bug that fails review and annoys customers.

Restore is also one entry in the longer list of predictable rejection triggers; see how to avoid App Store rejection for the rest.

Common gotchas

  • Do not auto-sync on launch. Calling AppStore.sync() at startup can throw a sign-in prompt at every launch. Read Transaction.currentEntitlements on launch instead, and keep sync() on the button.
  • Restore is idempotent. Running it twice does nothing harmful and never charges again. Treat "nothing to restore" as a normal, successful outcome, not an error.
  • Test on a real device with a Sandbox account. Restore behavior depends on the Apple ID's purchase history, which a fresh simulator will not have. Buy in the sandbox, delete the app, reinstall, and confirm restore brings the purchase back.
  • A StoreKit configuration file is great for the purchase flow, less so for restore. Local config testing does not exercise the real Apple ID sync, so validate restore against sandbox as well. For local paywall testing, see testing with a StoreKit configuration file.

FAQ

Do I still need a restore button with StoreKit 2?

Yes. Even though Transaction.currentEntitlements syncs automatically, App Review guideline 3.1.1 requires an explicit restore mechanism for non-consumable and auto-renewable purchases, and it is the fallback when the automatic sync has not yet run.

What does restore purchases actually do?

It asks the App Store for everything the customer's Apple ID owns and re-applies those entitlements in your app. With StoreKit 2 that is AppStore.sync() followed by reading Transaction.currentEntitlements; with RevenueCat it is restorePurchases().

Will restoring purchases charge the customer again?

No. Restore only re-applies purchases the Apple ID already made. If your restore button ever triggers a charge, you are calling a purchase API by mistake, which will also fail App Review.

Why can't the customer just re-download and have it work?

Often they can, because StoreKit 2 syncs entitlements automatically. But a fresh install before the first sync, or an edge case in the transaction cache, can leave the app unaware of a purchase until restore forces the sync. That is exactly what the button is for.

Where should the Restore Purchases button live?

On the paywall and in a persistent place like Settings, labelled plainly as "Restore Purchases." Reviewers and returning customers both expect to find it without hunting.

Does RevenueCat handle restore for me?

Largely, yes. Purchases.shared.restorePurchases() is the single call, and RevenueCat's prebuilt PaywallView already includes a restore button. If you use custom app user IDs, review how restore interacts with account transfers before shipping.


A compliant restore flow is one of the small things that has to be right before an app can pass review, and it is easy to forget until a rejection lands. Spaceport ships it wired. The generated SwiftUI project includes the RevenueCat paywall via RevenueCatSubscriptionKit and RevenueCatUI's PaywallView, which already has a working restore control, over subscription products Spaceport creates and prices in App Store Connect through the API. So restore, purchase, and the paywall all work on the first build instead of being a review-day scramble. And when you are lining up a waitlist and launch-day audience for the app, our sister tool Lighthouse covers that side.

From an indie iOS dev, for indie iOS devs.

Read more at spaceport.build

Community appsJoin Discord