What can product.purchase() return or throw, and how should each outcome behave in the UI?
Predict first
Does a handler that covers every PurchaseResult, PurchaseError and StoreKitError case compile against the StoreKit SDK?
import StoreKit
enum Outcome { case delivered(String), cancelled, pending, failed(String) }
@MainActor func buy(_ product: Product, user: UUID) async -> Outcome {
do {
let result = try await product.purchase(options: [.appAccountToken(user)])
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await transaction.finish()
return .delivered(transaction.productID)
case .unverified(_, let error):
return .failed("unverified: \(error)")
}
case .userCancelled: return .cancelled
case .pending: return .pending
@unknown default: return .failed("unknown result")
}
} catch let error as Product.PurchaseError {
switch error {
case .productUnavailable, .purchaseNotAllowed, .ineligibleForOffer: return .failed("\(error)")
default: return .failed("purchase error \(error)")
}
} catch let error as StoreKitError {
switch error {
case .userCancelled: return .cancelled
case .networkError, .notAvailableInStorefront, .notEntitled, .systemError, .unknown: return .failed("\(error)")
default: return .failed("\(error)")
}
} catch { return .failed("\(error)") }
}
print("compiled")
- Yes: success (verified or unverified), userCancelled, pending, and the thrown error types all compile
- No: PurchaseResult has a failure case
- No: purchase(options:) is not available on macOS
Mental model. A purchase is a letter to a bank: it can come back approved, declined by you (cancelled), or "we will write when the parent signs" (pending), and only the first one gives you the goods.
purchase(options:) returns a Product.PurchaseResult with three cases and can also throw. .success(VerificationResult<Transaction>) means the payment went through, but you must still verify, deliver, and call finish() (next questions). .userCancelled is a normal outcome, not an error: show nothing alarming. .pending means the purchase is waiting for an outside decision (Ask to Buy approval, strong customer authentication, a deferred payment), so keep the UI in a neutral "waiting" state and do not grant access; the approval later arrives through Transaction.updates, possibly in a later launch. Thrown errors are Product.PurchaseError (for example .productUnavailable, .purchaseNotAllowed when purchases are restricted by Screen Time, .ineligibleForOffer, or invalid offer signature errors) and StoreKitError (.networkError, .notAvailableInStorefront, .userCancelled, .systemError). The check compiles an exhaustive handler for all of these; it does not run it against a store.
Pass options where they matter: .appAccountToken(uuid) ties the transaction to your user id so a server can attribute a webhook (the token must be a UUID you generate and store, so use a random one per user, not an email), .quantity for consumables, and offer options. On iOS 17 and later prefer purchase(confirmIn:options:) with the scene that should present the payment sheet, which matters on iPad multi-window and when a SwiftUI view model has no obvious window; SwiftUI also gives you @Environment(\.purchase). Disable the buy button while the call is in flight (a double tap should not start two purchases), and re-enable on every exit path including thrown errors. Never infer success from the absence of an error: only .success with a verified transaction grants content.
The compile check only proves the API shapes; no store was contacted.
Common traps
- Treating
.pendingas failure or as success. It is neither: show a waiting message and rely onTransaction.updatesfor the outcome. - Showing an error alert on cancel. The user pressed Cancel; swallow it.
They might follow up with
- How does the app learn that Ask to Buy was approved? Through
Transaction.updates, even after a relaunch. - What does
appAccountTokencost you? Nothing visible to users, but it must be a UUID; one per account is stable and does not leak personal data.
Related questions
- 28.1.1 Which product types does StoreKit 2 sell, why can Product.products(for:) come back empty, and why must prices…
- 28.1.3 What does a .verified StoreKit result prove, what does it not prove, and when would you read…
- 28.1.4 Why start a Transaction.updates listener at launch, and what do finish() and idempotent delivery guarantee?
- 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…
← Which product types does StoreKit 2 sell, why can… What does a .verified StoreKit result prove, what does it not prove… →