Original StoreKit spreads a purchase across a products request delegate, a payment queue observer, and manual receipt parsing to figure out what a user actually owns — three separate callback surfaces for one linear flow.
#The old way
import StoreKit
final class LegacyStore: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
guard let product = response.products.first else { return }
SKPaymentQueue.default().add(SKPayment(product: product))
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions where transaction.transactionState == .purchased {
SKPaymentQueue.default().finishTransaction(transaction)
}
}
}
#The new way
import StoreKit
final class Store {
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
}
enum StoreError: Error {
case failedVerification
}
#Why it matters
Product.purchase()is a single async call — no payment queue observer, no delegate methods split across the appVerificationResultgives cryptographic verification of the transaction locally, no server round trip needed just to check it's legitimateTransaction.currentEntitlementsreplaces manual receipt parsing for checking what a user currently ownsTransaction.updatesis anAsyncSequenceyou can iterate to react to renewals, refunds, and cross-device purchases as they happen
#Gotcha
You still must call transaction.finish() after delivering the purchased content, or StoreKit keeps redelivering that transaction on every launch — it's easy to assume the async call returning means the purchase is fully closed out. Also, handling .unverified isn't optional to skip: a jailbroken device or a replayed receipt can produce an unverified transaction, and treating it as valid anyway defeats the point of StoreKit 2's local verification. Exact Product and Transaction API shapes have grown since iOS 15; confirm current parameter names against Apple's docs before shipping.