For over a decade, implementing In-App Purchases (IAP) on iOS meant one thing: StoreKit 1. It was a rite of passage for iOS developers—wrangling with SDKPaymentQueue, debugging obscure receipt validation errors, and managing a labyrinth of delegate callbacks.
Then came StoreKit 2.
Introduced with iOS 15, StoreKit 2 is not just an update; it is a complete paradigm shift. It replaces the callback-heavy, observer-based patterns of the past with modern Swift concurrency (async/await). It is cleaner, safer, and infinitely more readable.
However, migrating an existing app is rarely simple. Here is your survival guide for making the jump without breaking your revenue stream.
1. The Mental Shift: From Observers to Await
The biggest structural change is the removal of the transaction observer.
In StoreKit 1, you had to register a listener early in your app lifecycle and handle state changes (Purchased, Failed, Restored, Deferred) in a centralized, often massive, updatedTransactions method.
In StoreKit 2, purchasing is a linear, asynchronous operation. You ask for a purchase, and you wait for the result right there on the calling line.
The Code Contrast
StoreKit 1 (The Old Way):
// 1. Add Observer
SKPaymentQueue.default().add(self)
// 2. Trigger Purchase
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
// 3. Handle Callback (Somewhere else entirely)
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// Unlock content, verify receipt, finish transaction
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
// Handle error, finish transaction
SKPaymentQueue.default().finishTransaction(transaction)
// ... handle restoring, deferring
}
}
}
StoreKit 2 (The New Way):
// 1. Trigger Purchase and Handle Result immediately
let result = try await product.purchase()
switch result {
case .success(let verification):
// Check JWS signature
let transaction = try checkVerified(verification)
// Unlock content
await transaction.finish()
case .userCancelled:
// Handle cancellation
case .pending:
// Handle "Ask to Buy"
}
2. Receipt Validation: Goodbye OpenSSL
In StoreKit 1, "Receipt Validation" was a notorious pain point. You had to load a local receipt file (appStoreReceiptURL), base64 encode it, and send it to Apple’s verify endpoint (or try to parse the ASN.1 container locally using OpenSSL).
StoreKit 2 handles this elegantly. Transactions are returned as JWS (JSON Web Signature) tokens.
- You don't need to contact Apple's servers to verify them.
- You verify the signature locally using the device's root certificate.
- The API provides a helper checkVerified to do this automatically.
3. Handling History & Restores
StoreKit 1 relied on restoreCompletedTransactions(), which would replay old transactions and trigger your observer again. It was often slow and confusing for users (forcing a login prompt).
StoreKit 2 introduces Transaction.currentEntitlements. This is an asynchronous sequence that returns only the active, valid subscriptions and non-consumables the user owns. It automatically handles revocations and expirations.
for await result in Transaction.currentEntitlements {
if let transaction = try? result.payloadValue {
// Grant access based on transaction.productID
}
}
4. The "Gotchas"
Migration isn't without risks. Here are the traps to avoid:
- Pending Transactions: Even with async/await, you still need a listener for transactions that complete outside the app (e.g., subscription renewals or "Ask to Buy" approvals). In StoreKit 2, you set this up via Transaction.updates at app launch.
- iOS Compatibility: StoreKit 2 is iOS 15+ only. If you still support iOS 14 or earlier, you must maintain both codebases or wrap them in #available checks. This duality is often the biggest source of bugs.
- Server-Side Logic: If you have a backend validating receipts, you need to update it to handle the new App Store Server API, which works differently than the old verifyReceipt endpoint.
The Crosspay Shortcut
If maintaining two separate billing stacks (SK1 for legacy, SK2 for modern) sounds exhausting, there is a third option.
Crosspay abstracts this entire migration away. Our SDK automatically uses StoreKit 2 on supported devices and falls back to StoreKit 1 on older OS versions seamlessly. You write one line of code—Crosspay.purchase()—and we handle the OS version checks, receipt validation, and transaction finishing for you.
Why rebuild the plumbing when you can just turn on the tap?
Enjoyed this article?
Subscribe to our newsletter to receive the latest updates, tutorials, and insights directly in your inbox.