How to Restore Purchases in SwiftUI
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
-
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.userCancelledis caught separately and treated as a non-error exit. -
Transaction.currentEntitlements — An
AsyncSequencethat yields oneVerificationResult<Transaction>per currently-active product. "Active" means not expired, not refunded, and not revoked — so subscriptions that have lapsed will not appear here. -
VerificationResult pattern match — Unwrapping
.verified(let tx)proves Apple's cryptographic signature on the transaction is valid. Discarding.unverifiedsilently skips tampered or unreadable payloads rather than crashing or trusting bad data. -
Transaction.updates listener — Started in
.taskand cancelled inonDisappear, this long-lived Task ensures subscription renewals, refunds, and family-sharing grants update entitlements in real time while the view is on screen. -
@Observable store — Because
PurchaseStoreis@Observable, any mutation topurchasedIDsorisRestoringautomatically triggers the minimum necessary SwiftUI re-render — noobjectWillChangeboilerplate 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
-
🚫 Skipping AppStore.sync() and wondering why entitlements are stale.
Transaction.currentEntitlementsreads the local signed receipt, which may lag behind Apple's servers. Always callAppStore.sync()when the user explicitly taps "Restore" so the receipt is refreshed first. -
🚫 Trusting .unverified results. Pattern-matching only on
.verifiedis not optional —.unverifiedpayloads have failed Apple's cryptographic check and should never unlock premium content. This is a common attack vector in jailbroken environments. -
🚫 Forgetting revocationDate. A transaction can be active in
currentEntitlementsbut revoked (e.g., refunded). Always checktx.revocationDate == nilbefore granting access, or you will re-grant entitlements Apple has explicitly recalled. -
🚫 Not finishing transactions. In the
Transaction.updateslistener, callawait tx.finish()after processing each transaction. Un-finished transactions keep appearing in the update stream indefinitely and can cause duplicate processing.
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?
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?
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.