storekit · git:20260329.893209f · 2026-03-29 · sha256 41c0cd68c31f3266
storekit git:20260329.893209fA
Immutable. This exact content is served forever at /api/v1/blob/41c0cd68c31f3266.
---
name: storekit
description: "Implement, review, or improve in-app purchases and subscriptions using StoreKit 2. Use when building paywalls with SubscriptionStoreView or ProductView, processing transactions with Product and Transaction APIs, verifying entitlements, handling purchase flows (consumable, non-consumable, auto-renewable), implementing offer codes or promotional/win-back/introductory offers, managing subscription status and renewal state, setting up StoreKit testing with configuration files, or integrating Family Sharing, Ask to Buy, refund handling, and billing retry logic."
---
# StoreKit 2 In-App Purchases and Subscriptions
Implement in-app purchases, subscriptions, and paywalls using StoreKit 2 on
iOS 26+. Use the modern `Product`, `Transaction`, `StoreView`, and
`SubscriptionStoreView` APIs. Avoid the older original StoreKit APIs
(`SKProduct`, `SKPaymentQueue`, `SKStoreReviewController`).
## Contents
- [Product Types](#product-types)
- [Loading Products](#loading-products)
- [Purchase Flow](#purchase-flow)
- [Transaction.updates Listener](#transactionupdates-listener)
- [Entitlement Checking](#entitlement-checking)
- [SubscriptionStoreView (iOS 17+)](#subscriptionstoreview-ios-17)
- [StoreView (iOS 17+)](#storeview-ios-17)
- [Subscription Status Checking](#subscription-status-checking)
- [Restore Purchases](#restore-purchases)
- [App Transaction (App Purchase Verification)](#app-transaction-app-purchase-verification)
- [Purchase Options](#purchase-options)
- [SwiftUI Purchase Callbacks](#swiftui-purchase-callbacks)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#references)
## Product Types
| Type | Enum Case | Behavior |
|---|---|---|
| **Consumable** | `.consumable` | Used once, can be repurchased (gems, coins) |
| **Non-consumable** | `.nonConsumable` | Purchased once permanently (premium unlock) |
| **Auto-renewable** | `.autoRenewable` | Recurring billing with automatic renewal |
| **Non-renewing** | `.nonRenewing` | Time-limited access without automatic renewal |
## Loading Products
Define product IDs as constants. Fetch products with `Product.products(for:)`.
```swift
import StoreKit
enum ProductID {
static let premium = "com.myapp.premium"
static let gems100 = "com.myapp.gems100"
static let monthlyPlan = "com.myapp.monthly"
static let yearlyPlan = "com.myapp.yearly"
static let all: [String] = [premium, gems100, monthlyPlan, yearlyPlan]
}
let products = try await Product.products(for: ProductID.all)
for product in products {
print("\(product.displayName): \(product.displayPrice)")
}
```
## Purchase Flow
Call `product.purchase(options:)` and handle all three `PurchaseResult` cases.
Always verify and finish transactions.
```swift
func purchase(_ product: Product) async throws {
let result = try await product.purchase(options: [
.appAccountToken(userAccountToken)
])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await deliverContent(for: transaction)
await transaction.finish()
case .userCancelled:
break
case .pending:
// Ask to Buy or deferred approval -- do not unlock content yet
break
@unknown default:
break
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value): return value
case .unverified(_, let error): throw error
}
}
```
## Transaction.updates Listener
Start at app launch. Catches purchases from other devices, Family Sharing
changes, renewals, Ask to Buy approvals, refunds, and revocations.
```swift
@main
struct MyApp: App {
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = listenForTransactions()
}
var body: some Scene {
WindowGroup { ContentView() }
}
func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await StoreManager.shared.updateEntitlements()
await transaction.finish()
}
}
}
}
```
## Entitlement Checking
Use `Transaction.currentEntitlements` for non-consumable purchases and active
subscriptions. Always check `revocationDate`.
```swift
@Observable
@MainActor
class StoreManager {
static let shared = StoreManager()
var purchasedProductIDs: Set<String> = []
var isPremium: Bool { purchasedProductIDs.contains(ProductID.premium) }
func updateEntitlements() async {
var purchased = Set<String>()
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
purchasedProductIDs = purchased
}
}
```
### SwiftUI .currentEntitlementTask Modifier
```swift
struct PremiumGatedView: View {
@State private var state: EntitlementTaskState<VerificationResult<Transaction>?> = .loading
var body: some View {
Group {
switch state {
case .loading: ProgressView()
case .failure: PaywallView()
case .success(let transaction):
if transaction != nil { PremiumContentView() }
else { PaywallView() }
}
}
.currentEntitlementTask(for: ProductID.premium) { state in
self.state = state
}
}
}
```
## SubscriptionStoreView (iOS 17+)
Built-in SwiftUI view for subscription paywalls. Handles product loading,
purchase UI, and restore purchases automatically.
```swift
SubscriptionStoreView(groupID: "YOUR_GROUP_ID")
.subscriptionStoreControlStyle(.prominentPicker)
.subscriptionStoreButtonLabel(.multiline)
.storeButton(.visible, for: .restorePurchases)
.storeButton(.visible, for: .redeemCode)
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
```
### Custom Marketing Content
```swift
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
VStack {
Image(systemName: "crown.fill").font(.system(size: 60)).foregroundStyle(.yellow)
Text("Unlock Premium").font(.largeTitle.bold())
Text("Access all features").foregroundStyle(.secondary)
}
}
.containerBackground(.blue.gradient, for: .subscriptionStore)
```
### Hierarchical Layout
```swift
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
SubscriptionPeriodGroupSet()
}
.subscriptionStoreControlStyle(.picker)
```
## StoreView (iOS 17+)
Merchandises multiple products with localized names, prices, and purchase buttons.
```swift
StoreView(ids: [ProductID.gems100, ProductID.premium], prefersPromotionalIcon: true)
.productViewStyle(.large)
.storeButton(.visible, for: .restorePurchases)
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
```
### ProductView for Individual Products
```swift
ProductView(id: ProductID.premium) { iconPhase in
switch iconPhase {
case .success(let image): image.resizable().scaledToFit()
case .loading: ProgressView()
default: Image(systemName: "star.fill")
}
}
.productViewStyle(.large)
```
## Subscription Status Checking
```swift
func checkSubscriptionActive(groupID: String) async throws -> Bool {
let statuses = try await Product.SubscriptionInfo.Status.status(for: groupID)
for status in statuses {
guard case .verified = status.renewalInfo,
case .verified = status.transaction else { continue }
if status.state == .subscribed || status.state == .inGracePeriod {
return true
}
}
return false
}
```
### Renewal States
| State | Meaning |
|---|---|
| `.subscribed` | Active subscription |
| `.expired` | Subscription has expired |
| `.inBillingRetryPeriod` | Payment failed, Apple is retrying |
| `.inGracePeriod` | Payment failed but access continues during grace period |
| `.revoked` | Apple refunded or revoked the subscription |
## Restore Purchases
StoreKit 2 handles restoration via `Transaction.currentEntitlements`. Add a
restore button or call `AppStore.sync()` explicitly.
```swift
func restorePurchases() async throws {
try await AppStore.sync()
await StoreManager.shared.updateEntitlements()
}
```
On store views: `.storeButton(.visible, for: .restorePurchases)`
## App Transaction (App Purchase Verification)
Verify the legitimacy of the app installation. Use for business model changes
or detecting tampered installations (iOS 16+).
```swift
func verifyAppPurchase() async {
do {
let result = try await AppTransaction.shared
switch result {
case .verified(let appTransaction):
let originalVersion = appTransaction.originalAppVersion
let purchaseDate = appTransaction.originalPurchaseDate
// Migration logic for users who paid before subscription model
case .unverified:
// Potentially tampered -- restrict features as appropriate
break
}
} catch { /* Could not retrieve app transaction */ }
}
```
## Purchase Options
```swift
// App account token for server-side reconciliation
try await product.purchase(options: [.appAccountToken(UUID())])
// Consumable quantity
try await product.purchase(options: [.quantity(5)])
// Simulate Ask to Buy in sandbox
try await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])
```
## SwiftUI Purchase Callbacks
```swift
.onInAppPurchaseStart { product in
return true // Return false to cancel
}
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
.inAppPurchaseOptions { product in
[.appAccountToken(userAccountToken)]
}
```
## Common Mistakes
### 1. Not starting Transaction.updates at app launch
```swift
// WRONG: No listener -- misses renewals, refunds, Ask to Buy approvals
@main struct MyApp: App {
var body: some Scene { WindowGroup { ContentView() } }
}
// CORRECT: Start listener in App init (see Transaction.updates section above)
```
### 2. Forgetting transaction.finish()
```swift
// WRONG: Never finished -- reappears in unfinished queue forever
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
// CORRECT: Always finish after delivering content
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
await transaction.finish()
```
### 3. Ignoring verification result
```swift
// WRONG: Using unverified transaction -- security risk
let transaction = verification.unsafePayloadValue
// CORRECT: Verify before using
let transaction = try checkVerified(verification)
```
### 4. Using legacy original StoreKit APIs
```swift
// AVOID: Original StoreKit (legacy)
let request = SKProductsRequest(productIdentifiers: ["com.app.premium"])
SKPaymentQueue.default().add(payment)
SKStoreReviewController.requestReview()
// PREFERRED: StoreKit 2
let products = try await Product.products(for: ["com.app.premium"])
let result = try await product.purchase()
try await AppStore.requestReview(in: windowScene)
```
### 5. Not checking revocationDate
```swift
// WRONG: Grants access to refunded purchases
if case .verified(let transaction) = result {
purchased.insert(transaction.productID)
}
// CORRECT: Skip revoked transactions
if case .verified(let transaction) = result, transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
```
### 6. Hardcoding prices
```swift
// WRONG: Wrong for other currencies and regions
Text("Buy Premium for $4.99")
// CORRECT: Localized price from Product
Text("Buy \(product.displayName) for \(product.displayPrice)")
```
### 7. Not handling .pending purchase result
```swift
// WRONG: Silently drops pending Ask to Buy
default: break
// CORRECT: Inform user purchase is awaiting approval
case .pending:
showPendingApprovalMessage()
```
### 8. Checking entitlements only once at launch
```swift
// WRONG: Check once, never update
func appDidFinish() { Task { await updateEntitlements() } }
// CORRECT: Re-check on Transaction.updates AND on foreground return
// Transaction.updates listener handles mid-session changes.
// Also use .task { await storeManager.updateEntitlements() } on content views.
```
### 9. Missing restore purchases button
```swift
// WRONG: No restore option -- App Store rejection risk
SubscriptionStoreView(groupID: "group_id")
// CORRECT
SubscriptionStoreView(groupID: "group_id")
.storeButton(.visible, for: .restorePurchases)
```
### 10. Subscription views without policy links
```swift
// WRONG: No terms or privacy policy
SubscriptionStoreView(groupID: "group_id")
// CORRECT
SubscriptionStoreView(groupID: "group_id")
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
```
## Review Checklist
- [ ] `Transaction.updates` listener starts at app launch in App init
- [ ] All transactions verified before granting access
- [ ] `transaction.finish()` called after content delivery
- [ ] Revoked transactions excluded from entitlements
- [ ] `.pending` purchase result handled for Ask to Buy
- [ ] Restore purchases button visible on paywall and store views
- [ ] Terms of Service and Privacy Policy links on subscription views
- [ ] Prices shown using `product.displayPrice`, never hardcoded
- [ ] Subscription terms (price, duration, renewal) clearly displayed
- [ ] Free trial states post-trial pricing clearly
- [ ] No original StoreKit APIs (`SKProduct`, `SKPaymentQueue`)
- [ ] Product IDs defined as constants, not scattered strings
- [ ] StoreKit configuration file set up for testing
- [ ] Entitlements re-checked on Transaction.updates and app foreground
- [ ] Server-side validation uses `jwsRepresentation` if applicable
- [ ] Consumables delivered and finished promptly
- [ ] Transaction observer types and product model types are `Sendable` when shared across concurrency boundaries
## References
- See [references/app-review-guidelines.md](references/app-review-guidelines.md) for IAP rules (Guideline 3.1.1),
subscription display requirements, and rejection prevention.
- See [references/storekit-advanced.md](references/storekit-advanced.md) for subscription control styles,
offer management, testing patterns, and advanced subscription handling.