StoreKit 2 In-App Purchases¶
StoreKit 2 is Apple's modern API for in-app purchases. It uses async/await, provides built-in receipt verification, and simplifies the purchase flow compared to StoreKit 1. This entry covers non-consumable purchases with testing via StoreKit configuration files.
Key Facts¶
- StoreKit 2 uses
Product.products(for:)to fetch available products product.purchase()triggers the system purchase sheetTransaction.updateswatches for purchases made outside the app- Purchase results include
.success,.userCancelled, and.pendingcases - Verified transactions include
signedType.productIDto track what was purchased - StoreKit Configuration File (
.storekit) enables testing without App Store Connect - Non-consumable = buy once permanently; consumable = repeatable; subscription = recurring
Patterns¶
Setup: StoreKit Configuration File¶
- New File > StoreKit Configuration File
- Click
+> Add Non-Consumable In-App Purchase - Set Reference Name (e.g.,
HP4), Product ID (e.g.,hp4), Price - Add Display Name and Description
- Product > Scheme > Edit Scheme > StoreKit Configuration: select the file
Store Class¶
import StoreKit
@MainActor
class Store: ObservableObject {
@Published var books: [BookStatus] = [
.active, .active, .inactive,
.locked, .locked, .locked, .locked
]
@Published var products: [Product] = []
@Published var purchasedIDs = Set<String>()
private let productIDs = ["hp4", "hp5", "hp6", "hp7"]
private var updates: Task<Void, Never>? = nil
init() {
updates = watchForUpdates()
}
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
} catch {
print("Couldn't fetch products: \(error)")
}
}
func purchase(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
switch verificationResult {
case .unverified(_, let error):
print("Purchase unverified: \(error)")
case .verified(let signedType):
purchasedIDs.insert(signedType.productID)
@unknown default:
break
}
case .userCancelled:
break
case .pending:
break // waiting for parent approval
@unknown default:
break
}
} catch {
print("Couldn't purchase: \(error)")
}
}
func checkPurchased() async {
for product in products {
guard let state = await product.currentEntitlement else { return }
switch state {
case .unverified(_, let error):
print("Error: \(error)")
case .verified(let signedType):
if signedType.revocationDate == nil {
purchasedIDs.insert(signedType.productID)
} else {
purchasedIDs.remove(signedType.productID)
}
@unknown default:
break
}
}
}
private func watchForUpdates() -> Task<Void, Never> {
Task(priority: .background) {
for await _ in Transaction.updates {
await checkPurchased()
}
}
}
}
Injecting Store via EnvironmentObject¶
@main
struct HPTriviaApp: App {
@StateObject private var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}
// In any child view
struct SettingsView: View {
@EnvironmentObject var store: Store
var body: some View {
ForEach(0..<7) { i in
if store.books[i] == .active { ... }
else if store.books[i] == .locked { ... }
}
}
}
Loading Products on View Appear¶
IAP Types¶
| Type | Purchase | Use Case |
|---|---|---|
| Non-consumable | Once, permanent | Unlock features/content |
| Consumable | Repeatable | In-game currency, lives |
| Non-renewing subscription | Manual renewal | Seasonal access |
| Auto-renewing subscription | Auto-billed | Ongoing service |
Purchase Flow Summary¶
- Create
.storekitconfig file with product IDs - Set scheme to use that config file for testing
Product.products(for: productIDs)- fetch metadataproduct.purchase()- trigger purchase sheet- Switch on result:
.success>.verified(signedType)> addproductIDtopurchasedIDs product.currentEntitlement- check existing purchases (on launch + after updates)Transaction.updates- watch for external purchases
Enum for Content Lock Status¶
enum BookStatus {
case active // selected
case inactive // unselected
case locked // locked behind IAP
}
Gotchas¶
- The
.storekitconfig file must be selected in the scheme for testing to work @MainActoris required on the Store class because it updates@PublishedpropertiesTransaction.updatesmust run continuously - store the Task to prevent it from being cancelledrevocationDate != nilmeans the purchase was refunded - remove from purchasedIDspendingstate occurs when Ask to Buy is enabled (parental controls)- StoreKit testing in Xcode uses the config file; real App Store testing requires TestFlight or sandbox accounts
Set<String>for purchasedIDs prevents duplicate entries when checking entitlements
See Also¶
- [[swiftui-state-and-data-flow]] - @EnvironmentObject for Store injection
- [[swiftui-navigation]] - presenting purchase UI in sheets
- [[swift-enums-and-optionals]] - BookStatus enum pattern