StoreKit 2 and In-App Purchases on iOS
StoreKit 2, introduced at WWDC 2021, revolutionized how developers integrate in-app purchases (IAPs) into their iOS apps. Building upon the foundation of the original StoreKit framework, StoreKit 2 offers a modern, async/await-based API that simplifies many complex aspects of IAP implementation, from fetching product information to processing transactions and handling subscription statuses. It brings improved reliability, better error handling, and a more streamlined development experience, leveraging Swift's concurrency features.
If you've struggled with the delegate-based patterns and manual receipt validation of StoreKit 1, you'll find StoreKit 2 a breath of fresh air. This article will guide you through the essentials of integrating StoreKit 2, covering product fetching, making purchases, processing transactions, and managing entitlements, all with practical Swift code examples.
Setting Up Your Project
Before diving into code, you'll need to configure your app for in-app purchases:
- App Store Connect: Create your IAPs (Consumable, Non-Consumable, Auto-Renewable Subscription, Non-Renewing Subscription) in App Store Connect. Make sure to note their Product IDs. These IDs are crucial for fetching products in your app.
- Xcode Capabilities: In your Xcode project, select your target, go to the "Signing & Capabilities" tab, and add the "In-App Purchase" capability. This links your app to the StoreKit framework.
Understanding StoreKit 2 Fundamentals
StoreKit 2 introduces several key types that form the backbone of your IAP implementation:
Product: Represents an item available for purchase in your app. It contains details like product ID, display name, description, and price. When you fetch products, you'll receive an array ofProductobjects.Transaction: Encapsulates a completed purchase. It includes vital information such as the product purchased, the purchase date, and most importantly, the verification status. StoreKit 2 automatically verifies transactions on-device, providing aVerificationResultenum (either.verifiedor.unverified).Storefront: Provides information about the user's current App Store storefront, including its ID and country code. Useful for displaying localized pricing.AppStore: The central entry point for StoreKit 2 operations. It provides static methods for fetching products, initiating purchases, and observing transaction updates.Transaction.updates: AnAsyncSequencethat delivers a continuous stream ofTransactionobjects as they are created, updated, or restored. This is the primary way to observe and process all transactions in your app.
Fetching Products
The first step is to fetch the product information from the App Store. You'll need the Product IDs you configured in App Store Connect.
import StoreKit
class StoreManager: ObservableObject {
@Published var products: [Product] = []
// Replace with your actual product IDs from App Store Connect
private let productIDs = ["com.yourapp.premium_feature", "com.yourapp.monthly_subscription"]
func requestProducts() async {
do {
// Request products from the App Store
let storeProducts = try await Product.products(for: productIDs)
// Update on the main actor since @Published vars need main thread access
await MainActor.run {
// Sort them for consistent display
self.products = storeProducts.sorted { $0.id < $1.id }
print("Fetched \(self.products.count) products.")
for product in self.products {
print("Product: \(product.displayName) - \(product.displayPrice)")
}
}
} catch {
print("Failed to fetch products: \(error)")
}
}
}
You can call requestProducts() when your app launches or when your IAP store view appears. It's an async function, so remember to call it within a Task or an async context.
Making a Purchase
Once you have your Product objects, initiating a purchase is straightforward. The Product.purchase() method handles the entire purchase flow, including presenting the App Store purchase sheet.
extension StoreManager {
func purchase(_ product: Product) async throws -> Transaction? {
// Begin a purchase transaction.
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
// The purchase was successful. Verify and process the transaction.
let transaction = try checkVerified(verificationResult)
print("Purchase successful for product: \(transaction.productID)")
return transaction
case .userCancelled:
print("Purchase cancelled by user.")
return nil
case .pending:
print("Purchase is pending, awaiting external action.")
return nil
@unknown default:
// Handle new cases that may be introduced in future StoreKit versions.
print("Unknown purchase result.")
return nil
}
}
// Helper to check the verification result and return the verified transaction.
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(let unverifiedTransaction, let error):
// Handle unverified transactions.
// This could be due to a server issue, a tampered receipt, etc.
// For production apps, you might want to log this and potentially
// fall back to server-side receipt validation if necessary.
print("Unverified transaction: \(unverifiedTransaction), error: \(error)")
throw StoreError.failedVerification
case .verified(let verifiedTransaction):
return verifiedTransaction
}
}
}
enum StoreError: Error {
case failedVerification
}
The Product.purchase() method returns a PurchaseResult. The most important case is .success, which contains a VerificationResult<Transaction>. StoreKit 2 automatically performs on-device receipt validation. Your checkVerified helper function unwraps this, ensuring you only proceed with verified transactions.
Here's a simplified flow for a purchase:
┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────┐
│ User Taps Buy │────►│ Product.purchase() │────►│ App Store Sheet │
└─────────────────┘ └─────────────────────┘ └─────────────────┘
│ │
▼ │
┌─────────────────┐ │
│ PurchaseResult │◄─────────────────────────────────────┘
│ (.success, │
│ .userCancelled,│
│ .pending) │
└─────────────────┘
Processing Transactions and Handling Purchases
While product.purchase() gives you the result of a single purchase attempt, StoreKit 2 also provides a continuous stream of all transactions (new purchases, restores, refunds, subscription renewals) through Transaction.updates. You should always listen to this stream to ensure your app correctly grants entitlements and handles state changes, even if the app was not running when a transaction occurred.
This listener should ideally be set up early in your app's lifecycle, for example, in your App struct's init() or onAppear for a dedicated Store Manager.
extension StoreManager {
func listenForTransactions() {
Task(priority: .background) {
for await result in Transaction.updates {
do {
let transaction = try checkVerified(result)
await self.updateCustomerProductStatus(transaction: transaction)
await transaction.finish() // Mark transaction as consumed
} catch {
print("Transaction failed verification or processing: \(error)")
}
}
}
}
// This method would update your app's internal state,
// grant content, save to UserDefaults, Core Data, etc.
@MainActor
private func updateCustomerProductStatus(transaction: Transaction) async {
// Example: Granting access to a premium feature
if transaction.productID == "com.yourapp.premium_feature" {
// Update UI, save state, unlock content.
// You might have a @Published var `isPremium` that you set to true.
print("User now has premium feature access.")
} else if transaction.productID == "com.yourapp.monthly_subscription" {
// Handle subscription entitlements.
// For subscriptions, you'd typically check `transaction.expirationDate`
// and potentially fetch the full subscription status using `Product.SubscriptionInfo.status`.
print("User has an active monthly subscription.")
}
// Always ensure that only *purchased* and *not revoked* transactions grant entitlements.
// For production, you might want more sophisticated logic, especially for subscriptions.
}
}
The listenForTransactions() function creates a Task that continuously awaits new transactions from Transaction.updates. For each transaction, we verify it using our checkVerified helper, update the customer's entitlements, and then finish the transaction using await transaction.finish(). Finishing a transaction tells the App Store that your app has successfully processed it and it won't be delivered again.
Restoring Purchases
For non-consumable products and subscriptions, users expect to be able to restore their purchases if they reinstall the app or get a new device. StoreKit 2 simplifies this significantly. Instead of a separate "Restore Purchases" button that triggers a specific delegate method, you primarily rely on AppStore.sync() and iterating through Transaction.currentEntitlements.
AppStore.sync() ensures that your app's local receipt is up-to-date with the latest information from the App Store. Then, you can simply iterate through Transaction.currentEntitlements to get all valid, non-expired transactions associated with the user's Apple ID.
extension StoreManager {
func restorePurchases() async {
do {
// This syncs the local receipt with the App Store.
// It's good practice to call this periodically or when a user explicitly requests restore.
try await AppStore.sync()
// Iterate through all current entitlements for the user.
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
await updateCustomerProductStatus(transaction: transaction)
// No need to call transaction.finish() here, as these are already finished transactions
// being delivered for entitlement checks.
} catch {
print("Failed to restore an entitlement: \(error)")
}
}
print("Purchases restored successfully.")
} catch {
print("Failed to sync or restore purchases: \(error)")
}
}
}
You can call restorePurchases() from a UI button or when your app first launches to ensure all entitlements are correctly applied.
Subscription Management
StoreKit 2 provides robust tools for managing subscriptions. Each Product has a subscription property (Product.SubscriptionInfo?) that provides details like subscription group ID, renewal period, and introductory offers.
When processing a Transaction for a subscription, you can access transaction.expirationDate to determine when the subscription will expire. For more detailed status, especially for auto-renewable subscriptions, you can fetch Product.SubscriptionInfo.status which gives you the current status (e.g., active, expired, grace period) and renewal information.
For users to manage their subscriptions (e.g., change plan, cancel), Apple provides built-in StoreKit views:
import SwiftUI
import StoreKit
struct SubscriptionManagementView: View {
@State private var showManageSubscriptions = false
var body: some View {
Button("Manage Subscriptions") {
showManageSubscriptions = true
}
.manageSubscriptionsSheet(isPresented: $showManageSubscriptions)
}
}
Using .manageSubscriptionsSheet is the recommended way to let users manage their subscriptions without leaving your app, providing a consistent and secure experience.
Testing In-App Purchases
Testing IAPs is crucial and StoreKit 2 significantly improves the developer experience:
- StoreKit Testing in Xcode: You can create a
.storekitfile in your Xcode project to define your products locally. This allows for rapid testing without needing to configure products in App Store Connect or use a sandbox tester account initially. You can simulate various scenarios like successful purchases, failed purchases, refunds, and subscription renewals. - Sandbox Environment: For testing with actual App Store Connect configurations and sandbox user accounts, you'll still use the sandbox environment. Ensure your device is signed in with a sandbox tester Apple ID.
Error Handling
StoreKit operations can fail for various reasons (network issues, user cancellation, payment problems). Always wrap your StoreKit 2 calls in do-catch blocks or handle throws appropriately. Common errors you might encounter include Product.PurchaseError, StoreKitError (for general StoreKit issues), and custom errors you define (like StoreError.failedVerification). Providing clear feedback to the user when an error occurs is important for a good user experience.
Summary
StoreKit 2 offers a powerful and streamlined approach to integrating in-app purchases into your iOS applications. By leveraging Swift's async/await, it simplifies product fetching, transaction processing, and entitlement management, significantly reducing the boilerplate and complexity inherent in its predecessor. Key takeaways include:
- Using
Product.products(for:)to fetch IAP details. - Initiating purchases with
product.purchase()and handlingPurchaseResult. - Setting up a continuous listener for
Transaction.updatesto reliably process all transactions, including restores and renewals. - Verifying transactions on-device using
VerificationResult. - Simplifying subscription management and restoration with
Product.SubscriptionInfoandAppStore.sync(). - Utilizing StoreKit Testing in Xcode for efficient development.
Embracing StoreKit 2 not only makes your IAP implementation more robust and easier to maintain but also aligns your app with Apple's modern concurrency best practices.
Happy Swifting!