```html SwiftUI: How to Restore Purchases (iOS 17+, 2026)

How to Restore Purchases in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Transaction.currentEntitlements Updated: May 12, 2026
TL;DR

Iterate Transaction.currentEntitlements to find every active purchase, verify each one, then reflect results in your app state. Optionally call AppStore.sync() first so Apple fetches the freshest receipt.

func restorePurchases() async throws {
    try await AppStore.sync()
    for await result in Transaction.currentEntitlements {
        if case .verified(let tx) = result {
            await updateState(for: tx.productID)
        }
    }
}

Full implementation

The pattern below uses an @Observable store class that owns the entitlement state and exposes an async restore() method. The SwiftUI view binds to it directly, showing an in-progress spinner and surfacing any errors through an alert. A persistent transaction listener set up on .task keeps entitlements in sync even when purchases complete outside the app.

import SwiftUI
import StoreKit

// MARK: - Store

@Observable
final class PurchaseStore {
    var purchasedIDs: Set<String> = []
    var isRestoring = false
    var restoreError: String?

    /// Re-sync receipt with Apple, then reload active entitlements.
    func restore() async {
        isRestoring = true
        restoreError = nil
        defer { isRestoring = false }

        do {
            // Pull the freshest receipt from Apple's servers.
            try await AppStore.sync()
        } catch StoreKitError.userCancelled {
            // User dismissed the sign-in sheet — not a hard error.
            return
        } catch {
            restoreError = error.localizedDescription
            return
        }

        await reloadEntitlements()
    }

    /// Iterate all currently active entitlements without a server sync.
    func reloadEntitlements() async {
        var active: Set<String> = []
        for await result in Transaction.currentEntitlements {
            switch result {
            case .verified(let tx):
                // Check expiry for subscriptions; non-consumables have no expirationDate.
                if tx.revocationDate == nil {
                    active.insert(tx.productID)
                }
            case .unverified:
                // Tampered or unreadable receipt — skip.
                break
            }
        }
        purchasedIDs = active
    }

    /// Long-lived listener that reacts to StoreKit transaction updates
    /// (renewals, refunds, etc.) while the app is running.
    func listenForTransactions() -> Task<Void, Never> {
        Task.detached(priority: .background) { [weak self] in
            for await result in Transaction.updates {
                if case .verified(let tx) = result {
                    await tx.finish()
                    await self?.reloadEntitlements()
                }
            }
        }
    }
}

// MARK: - View

struct RestorePurchasesView: View {
    @State private var store = PurchaseStore()
    @State private var listenerTask: Task<Void, Never>?
    @State private var showError = false

    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "bag.badge.questionmark")
                .font(.system(size: 60))
                .foregroundStyle(.indigo)

            Text("Already purchased?")
                .font(.title2.bold())

            Text("Tap below to restore your previous purchases from the App Store.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)
                .padding(.horizontal)

            Button {
                Task { await store.restore() }
            } label: {
                Label(
                    store.isRestoring ? "Restoring…" : "Restore Purchases",
                    systemImage: "arrow.clockwise"
                )
                .frame(maxWidth: .infinity)
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
            .disabled(store.isRestoring)
            .overlay {
                if store.isRestoring {
                    ProgressView()
                        .tint(.white)
                        .padding(.trailing, 8)
                        .frame(maxWidth: .infinity, alignment: .trailing)
                }
            }
            .padding(.horizontal)

            if !store.purchasedIDs.isEmpty {
                VStack(alignment: .leading, spacing: 6) {
                    Text("Active entitlements")
                        .font(.footnote.bold())
                        .foregroundStyle(.secondary)
                    ForEach(Array(store.purchasedIDs.sorted()), id: \.self) { id in
                        Label(id, systemImage: "checkmark.seal.fill")
                            .font(.footnote)
                            .foregroundStyle(.green)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal)
            }

            Spacer()
        }
        .padding(.top, 48)
        .task {
            listenerTask = store.listenForTransactions()
            await store.reloadEntitlements()
        }
        .onDisappear { listenerTask?.cancel() }
        .alert("Restore failed", isPresented: $showError) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(store.restoreError ?? "Unknown error")
        }
        .onChange(of: store.restoreError) { _, error in
            showError = (error != nil)
        }
    }
}

#Preview {
    RestorePurchasesView()
}

How it works

  1. AppStore.sync() — This StoreKit 2 call contacts Apple's servers and refreshes the signed receipt for the current App Store account. It may present a sign-in sheet if the user isn't signed in, which is why StoreKitError.userCancelled is caught separately and treated as a non-error exit.
  2. Transaction.currentEntitlements — An AsyncSequence that yields one VerificationResult<Transaction> per currently-active product. "Active" means not expired, not refunded, and not revoked — so subscriptions that have lapsed will not appear here.
  3. VerificationResult pattern match — Unwrapping .verified(let tx) proves Apple's cryptographic signature on the transaction is valid. Discarding .unverified silently skips tampered or unreadable payloads rather than crashing or trusting bad data.
  4. Transaction.updates listener — Started in .task and cancelled in onDisappear, this long-lived Task ensures subscription renewals, refunds, and family-sharing grants update entitlements in real time while the view is on screen.
  5. @Observable store — Because PurchaseStore is @Observable, any mutation to purchasedIDs or isRestoring automatically triggers the minimum necessary SwiftUI re-render — no objectWillChange boilerplate needed.

Variants

Filter to a specific product type

If your app has both auto-renewable subscriptions and non-consumable one-time purchases, you can branch on tx.productType to handle each differently — for example, unlocking lifetime access vs. renewing a badge.

func reloadEntitlements() async {
    var subscriptions: Set<String> = []
    var nonConsumables: Set<String> = []

    for await result in Transaction.currentEntitlements {
        guard case .verified(let tx) = result,
              tx.revocationDate == nil else { continue }

        switch tx.productType {
        case .autoRenewable:
            subscriptions.insert(tx.productID)
        case .nonConsumable:
            nonConsumables.insert(tx.productID)
        default:
            break
        }
    }

    self.activeSubscriptions = subscriptions
    self.lifetimePurchases = nonConsumables
}

Show a "Nothing to restore" message

After reloadEntitlements() completes, check whether purchasedIDs.isEmpty is still true. If so, surface a sheet or alert telling the user that no prior purchases were found on this Apple ID — this is required by App Store Review guideline 3.1.1 so users aren't left confused after a restore attempt that yields nothing.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement restore purchases in SwiftUI for iOS 17+.
Use Transaction.currentEntitlements and AppStore.sync().
Handle StoreKitError.userCancelled gracefully (no alert).
Check tx.revocationDate == nil before granting entitlements.
Use @Observable for the store class — no ObservableObject.
Make it accessible (VoiceOver labels on the restore button and result list).
Add a #Preview with realistic sample data.

In Soarias's Build phase, drop this prompt into a feature branch alongside your existing StoreKit configuration file so Claude Code can wire the restore button directly into your paywall screen.

Related

FAQ

Does this work on iOS 16?
Transaction.currentEntitlements and AppStore.sync() are both StoreKit 2 APIs introduced in iOS 15, so they technically compile on iOS 16. However, this guide targets iOS 17+ because @Observable requires iOS 17. If you need iOS 16 support, replace @Observable with @MainActor final class … : ObservableObject and @Published properties — the StoreKit logic is identical.
Do I need to call AppStore.sync() every time, or just on the explicit Restore button?
Only call AppStore.sync() in response to an explicit user action — i.e., when they tap your "Restore Purchases" button. Calling it on app launch or automatically in the background violates App Store guideline 3.1.1, which requires a user-initiated restore flow. For background entitlement refreshes (e.g., subscription renewals), rely solely on the Transaction.updates listener.
What's the UIKit / SKPaymentQueue equivalent?
In the original StoreKit 1 (UIKit era) you called SKPaymentQueue.default().restoreCompletedTransactions() and handled results in SKPaymentTransactionObserver callbacks. StoreKit 2's AppStore.sync() + Transaction.currentEntitlements replaces this entirely with structured concurrency — no delegate, no notification center, and Apple-side signature verification built in. If you're migrating a legacy app, remove the SKPaymentTransactionObserver conformance entirely once you adopt StoreKit 2.

Last reviewed: 2026-05-12 by the Soarias team.

```