In-app purchases are the primary way apps generate revenue on the App Store outside of upfront payments. StoreKit 2, introduced at WWDC 2021 and extended each year since, provides a Swift-native API with async/await, automatic transaction verification via JWS signatures, and SwiftUI views for building storefronts.
This guide covers the full lifecycle: configuring products in App Store Connect, fetching and displaying them, handling purchases and transactions, checking entitlements, testing locally, and validating receipts.
StoreKit supports four product types. Picking the right one matters β you cannot change a productβs type after creation in App Store Connect.
| Type | Behavior | Family Sharing | Use Case |
|---|---|---|---|
| Consumable | Purchased, used once, purchased again | No | Coins, gems, boosts |
| Non-Consumable | Purchased once, never expires | Yes (opt-in) | Full-game unlock, ad removal |
| Auto-Renewable Subscription | Recurring billing until cancelled | Yes (opt-in) | Streaming, SaaS, cloud storage |
| Non-Renewing Subscription | Fixed duration, no auto-renewal | No | Limited-time content packs |
You can create up to 10,000 products per app across all types.
StoreKit 1 (iPhone OS 3.0, 2009) relied on SKProductsRequest, SKPaymentQueue, and delegate callbacks. StoreKit 2 replaces all of that with pure Swift concurrency:
| StoreKit 1 | StoreKit 2 |
|---|---|
SKProductsRequest + delegate |
Product.products(for:) β async |
SKPaymentQueue.default().add(payment) |
product.purchase() β async |
SKPaymentTransactionObserver |
Transaction.updates β async sequence |
SKReceiptRefreshRequest |
Transaction JWS verification built-in |
| Manual receipt validation | Automatic signature check on every Transaction |
StoreKit 2 requires iOS 15.0+ / macOS 12.0+. For apps that must support older OS versions, you can use StoreKit 1 as a fallback or use a revenue platform that abstracts both.
Before writing any code, configure your products:
com.example.app.premium), price tier, and localization.Products are identified by string IDs that match what you configured in App Store Connect:
import StoreKit
let productIDs: Set<String> = [
"com.example.app.premium",
"com.example.app.monthly",
"com.example.app.yearly"
]
func fetchProducts() async throws -> [Product] {
try await Product.products(for: productIDs)
}Each Product carries localized display data automatically β never hardcode prices:
let product: Product
product.displayName // "Premium Upgrade"
product.description // "Unlock all features forever"
product.displayPrice // "$4.99"
product.price // Decimal(4.99)
product.type // .nonConsumablePurchasing via product.purchase() is a single async call. The result covers all outcomes:
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verification.payloadValue
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
// Ask to Buy or deferred β wait for Transaction.updates
return nil
@unknown default:
return nil
}
}.success wraps a VerificationResult<Transaction>. StoreKit automatically validates the JWS signature from Appleβs servers. payloadValue returns the verified Transaction on success and throws if the signature is invalid β no manual receipt parsing required.
Always call transaction.finish() after delivering the content. Until you do, StoreKit continues delivering that transaction through Transaction.updates.
Transactions happen outside your purchase flow β subscription renewals, billing retries, or purchases on another device. Listen for them with Transaction.updates:
func listenForTransactions() -> Task<Void, Error> {
Task {
for await result in Transaction.updates {
let transaction = try result.payloadValue
await self.updateEntitlements(transaction)
await transaction.finish()
}
}
}Start this listener on launch, before the user sees any UI. StoreKit delivers missed transactions from the moment you start listening.
When your app launches, iterate Transaction.currentEntitlements to determine what the user has access to:
func checkEntitlements() async -> [String: Transaction] {
var entitlements: [String: Transaction] = [:]
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue else {
continue // Invalid signature β skip
}
entitlements[transaction.productID] = transaction
}
return entitlements
}For consumables, currentEntitlements does not include consumed products β this is the correct behavior since consumables have no ongoing entitlement. For non-consumables and active auto-renewable subscriptions, the latest verified transaction is included.
For non-renewing subscriptions, currentEntitlements can include finished transactions. StoreKit does not manage renewal state for this product type, so your app or server needs to enforce the subscription duration.
StoreKit provides three ready-made SwiftUI views that handle fetching, display, and purchasing automatically. You can use them in as little as one line:
import SwiftUI
import StoreKit
struct Paywall: View {
var body: some View {
ProductView(id: "com.example.app.premium") {
Image(systemName: "star.fill")
}
.productViewStyle(.large)
}
}Styles: .large (full detail), .regular (condensed), .compact (minimal).
StoreView(ids: [
"com.example.app.monthly",
"com.example.app.yearly"
])The StoreView lays out products vertically and handles individual purchase flows for each.
For auto-renewable subscriptions, SubscriptionStoreView provides a standardized signpost with group selection, introductory offers, and terms display:
SubscriptionStoreView(groupID: "com.example.app.subscriptions") {
Text("Unlock everything")
.font(.title)
}
.subscriptionStoreButtonLabel(.multiline)
.subscriptionStorePickerItemBackground(.thinMaterial)This view renders subscription tiers, handles loading states, and uses StoreKit-managed purchase controls so you do not need to build the purchase buttons yourself.
Important: You must still listen for Transaction.updates and verify entitlements even when using StoreKit views β the views handle the purchase UI but not your business logic.
StoreKit provides two testing approaches:
.storekit file with IDs, prices, and durations.Xcode simulates the App Store locally β no network calls, no real money. You can test:
// In simulator, StoreKit uses the config file automatically
// when your scheme points to it. No code changes needed.For end-to-end testing with Appleβs servers:
Sandbox uses accelerated subscription renewal (6xβ12x real time).
Always handle the three non-success outcomes gracefully:
| Outcome | Meaning | Action |
|---|---|---|
.userCancelled |
User tapped Cancel | Do nothing β restore UI state |
.pending |
Ask to Buy, STP, or deferred | Show pending UI, wait for Transaction.updates |
VerificationResult.unverified |
Signature did not validate | Log, do not entitle, alert user |
case .success(let verification):
switch verification {
case .verified(let transaction):
// Grant access
case .unverified:
throw StoreError.failedVerification
}For non-consumables and auto-renewable subscriptions, use Transaction.currentEntitlements on launch β StoreKit automatically keeps transaction and subscription status information available to your app.
Still provide an explicit restore action for users who think purchases are missing. In StoreKit 2, call AppStore.sync() only in response to that user action because it may show an App Store authentication prompt:
func restore() async throws {
try await AppStore.sync()
let entitlements = await checkEntitlements()
// Update your entitlement cache/UI from entitlements.
}In normal app launches, do not call sync() preemptively. Refresh currentEntitlements instead.
StoreKit 2βs Transaction includes automatic JWS verification. For most apps, checking VerificationResult.verified on each transaction is sufficient. If you need server-side validation:
transaction.jwsRepresentation string to your server.For StoreKit 1 compatibility or apps that need the legacy receipt, use AppStore.receiptURL:
if let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) {
// Send receiptData to server for validation
}Here is a minimal but complete store view using StoreKit 2:
import SwiftUI
import StoreKit
struct PurchaseListView: View {
@State private var products: [Product] = []
@State private var purchased = Set<String>()
var body: some View {
List(products, id: \.id) { product in
HStack {
VStack(alignment: .leading) {
Text(product.displayName).font(.headline)
Text(product.displayPrice).foregroundColor(.secondary)
}
Spacer()
if purchased.contains(product.id) {
Text("Owned").foregroundColor(.green)
} else {
Button("Buy") {
Task { try? await purchase(product) }
}
}
}
}
.task { await load() }
}
func load() async {
products = (try? await Product.products(for: [
"com.example.app.premium",
"com.example.app.monthly"
])) ?? []
for await result in Transaction.currentEntitlements {
if let tx = try? result.payloadValue {
purchased.insert(tx.productID)
}
}
}
func purchase(_ product: Product) async throws {
guard let tx = try await buy(product) else { return }
purchased.insert(tx.productID)
}
private func buy(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let tx = try verification.payloadValue
await tx.finish()
return tx
case .userCancelled, .pending:
return nil
@unknown default:
return nil
}
}
}Auto-renewable subscriptions add complexity beyond consumables and non-consumables:
Transaction.updates to receive the billing retry transaction.product.purchase(options:).Transaction.subscriptionStatus to check state (.subscribed, .expired, .inBillingRetry, .inGracePeriod).for await result in Transaction.currentEntitlements {
guard let tx = try? result.payloadValue,
let status = try? await tx.subscriptionStatus else { continue }
// status.state β check renewal state
}