# StoreKit 2 Core Patterns

Complete, copyable code for the core flows, followed by the ten common
mistakes with a wrong and a right version each. Control styles, offers,
testing, server validation and subscription edge cases are in
[storekit-advanced.md](storekit-advanced.md).

The examples share one entitlement store, one verification helper and one
completion handler. Files that also import SwiftUI spell the type
`StoreKit.Transaction`.

## Contents

- [Buying with PurchaseAction](#buying-with-purchaseaction)
- [Launch-Time Transaction Listener](#launch-time-transaction-listener)
- [Computing Entitlements](#computing-entitlements)
- [Subscription Paywall](#subscription-paywall)
- [Product Shelves: StoreView and ProductView](#product-shelves-storeview-and-productview)
- [Is a Plan Active?](#is-a-plan-active)
- [Verifying the Install with AppTransaction](#verifying-the-install-with-apptransaction)
- [Store View Callbacks](#store-view-callbacks)
- [Ten Mistakes, Fixed](#ten-mistakes-fixed)

## Buying with PurchaseAction

A custom SwiftUI button that buys through `PurchaseAction` (iOS 17):

```swift
import StoreKit
import SwiftUI

func verified<Value>(_ check: VerificationResult<Value>) throws -> Value {
    try check.payloadValue
}

struct BuyProButton: View {
    let product: Product
    let accountID: UUID
    @Environment(\.purchase) private var purchase
    @State private var awaitingApproval = false

    var body: some View {
        Button("Unlock for \(product.displayPrice)") {
            Task { await buy() }
        }
        .alert("Waiting for approval", isPresented: $awaitingApproval) {}
    }

    private func buy() async {
        let options: Set<Product.PurchaseOption> = [.appAccountToken(accountID)]
        do {
            switch try await purchase(product, options: options) {
            case .pending:
                awaitingApproval = true
            case .userCancelled:
                return
            case .success(let check):
                let receipt = try verified(check)
                try await EntitlementStore.shared.deliver(receipt)
                await receipt.finish()
            @unknown default:
                return
            }
        } catch {
            awaitingApproval = false
        }
    }
}
```

- `.success` carries a `VerificationResult`. `verified(_:)` reads its
  `payloadValue`, which returns the transaction when the signature checks out
  and throws the verification error when it does not. Deliver, then finish.
- `.userCancelled` does nothing.
- `.pending` means Ask to Buy or another deferred approval: tell the user and
  unlock nothing until the approved transaction arrives on
  `Transaction.updates`.
- `@unknown default` keeps the switch valid when Apple adds a case.

## Launch-Time Transaction Listener

Start it in the `App` initializer and keep the task for the life of the
process. Its type is `Task<Void, Never>`, so no thrown error can end it.

```swift
import StoreKit
import SwiftUI

@main
struct RecipesApp: App {
    private let purchaseWatcher: Task<Void, Never>

    init() {
        purchaseWatcher = Self.watchTransactions()
    }

    var body: some Scene {
        WindowGroup { Text("Recipes") }
    }

    private static func watchTransactions() -> Task<Void, Never> {
        Task(priority: .background) {
            for await incoming in StoreKit.Transaction.updates {
                if case .verified(let receipt) = incoming {
                    await EntitlementStore.shared.refresh()
                    await receipt.finish()
                }
            }
        }
    }
}
```

Unverified updates fall through untouched. For each verified one,
entitlements are recomputed first and the transaction finished afterwards.

A plain `Task` is enough here. It inherits the main actor from the `App`, but
the loop spends its time suspended in `for await` and the entitlement refresh
runs on the main-actor `EntitlementStore` anyway; `Task.detached` would only add
a hop and drop the task-local values.

## Computing Entitlements

```swift
import Observation
import StoreKit

@MainActor
@Observable
final class EntitlementStore {
    static let shared = EntitlementStore()

    private(set) var owned: Set<String> = []

    var hasPro: Bool { owned.contains(StoreItem.proUnlock) }

    func refresh() async {
        var active: Set<String> = []
        for await entry in StoreKit.Transaction.currentEntitlements {
            guard case .verified(let receipt) = entry,
                  receipt.revocationDate == nil else { continue }
            active.insert(receipt.productID)
        }
        owned = active
    }

    func deliver(_ receipt: StoreKit.Transaction) async throws {
        await refresh()
    }
}

func settle(_ outcome: Result<Product.PurchaseResult, any Error>) async {
    guard case .success(.success(let check)) = outcome,
          let receipt = try? verified(check) else { return }
    try? await EntitlementStore.shared.deliver(receipt)
    await receipt.finish()
}
```

`refresh()` starts from an empty set each time: only verified entries with no
`revocationDate` count, and the result replaces the old set in one
assignment. `settle(_:)` is the shared completion handler for store views:
the outer `Result` says whether the purchase call failed, the inner
`PurchaseResult` whether it succeeded, and the `VerificationResult` whether it
can be trusted.

### SwiftUI .currentEntitlementTask Modifier

For a single product, a view can gate itself (iOS 17):

```swift
import StoreKit
import SwiftUI

struct ProGate<Unlocked: View, Locked: View>: View {
    let productID: String
    @ViewBuilder var unlocked: Unlocked
    @ViewBuilder var locked: Locked
    @State private var gate: EntitlementTaskState<VerificationResult<StoreKit.Transaction>?> = .loading

    var body: some View {
        ZStack {
            switch gate {
            case .loading:
                ProgressView()
            case .failure:
                locked
            case .success(.some(.verified(let receipt))) where receipt.revocationDate == nil:
                unlocked
            case .success:
                locked
            @unknown default:
                locked
            }
        }
        .currentEntitlementTask(for: productID) { gate = $0 }
    }
}
```

## Subscription Paywall

The built-in view loads the group's products, shows the purchase UI and can
restore by itself.

```swift
import StoreKit
import SwiftUI

struct MembershipPaywall: View {
    let group: String
    let terms: URL
    let privacy: URL

    var body: some View {
        SubscriptionStoreView(groupID: group)
            .storeButton(.visible, for: .restorePurchases, .redeemCode)
            .subscriptionStorePolicyDestination(url: terms, for: .termsOfService)
            .subscriptionStorePolicyDestination(url: privacy, for: .privacyPolicy)
            .subscriptionStoreButtonLabel(.multiline)
            .subscriptionStoreControlStyle(.prominentPicker)
            .onInAppPurchaseCompletion { _, outcome in await settle(outcome) }
    }
}
```

### Custom Marketing Content

A trailing closure replaces the header; `.containerBackground(_:for:)` with
`.subscriptionStore` paints behind the whole paywall.

```swift
SubscriptionStoreView(groupID: group) {
    VStack(spacing: 8) {
        Image(systemName: "fork.knife.circle.fill")
            .font(.system(size: 56))
        Text("Recipes Plus").font(.largeTitle.bold())
        Text("Every cookbook, offline, on all your devices")
            .foregroundStyle(.secondary)
    }
    .padding()
}
.containerBackground(.orange.gradient, for: .subscriptionStore)
```

Grouped layouts (`SubscriptionOptionGroup`, `SubscriptionOptionSection`,
`SubscriptionPeriodGroupSet`) and the control styles are in
[storekit-advanced.md](storekit-advanced.md).

## Product Shelves: StoreView and ProductView

`StoreView` lists several products, each with its localized name, price and
buy button; `ProductView` shows one. `.productViewStyle(.large)` works on
both. The icon closure that receives a `ProductIconPhase` exists on
`ProductView(id:icon:placeholderIcon:)`, so an ID-based view with a phase
closure also needs `placeholderIcon`; `ProductView(_ product:icon:)` takes the
phase closure alone.

```swift
import StoreKit
import SwiftUI

struct AddOnShelf: View {
    let ids: [String]

    var body: some View {
        StoreView(ids: ids, prefersPromotionalIcon: true)
            .storeButton(.visible, for: .restorePurchases)
            .productViewStyle(.large)
            .onInAppPurchaseCompletion { _, outcome in await settle(outcome) }
    }
}

struct FeaturedPack: View {
    let id: String

    var body: some View {
        ProductView(id: id) { phase in
            if case .success(let art) = phase {
                art.resizable().aspectRatio(contentMode: .fit)
            } else if case .loading = phase {
                ProgressView()
            } else {
                Image(systemName: "shippingbox")
            }
        } placeholderIcon: {
            ProgressView()
        }
        .productViewStyle(.large)
    }
}
```

## Is a Plan Active?

```swift
func hasActivePlan(in group: String) async throws -> Bool {
    let statuses = try await Product.SubscriptionInfo.status(for: group)
    return statuses.contains { entry in
        guard case .verified = entry.transaction,
              case .verified = entry.renewalInfo else { return false }
        return [.subscribed, .inGracePeriod].contains(entry.state)
    }
}
```

A status counts only when both its renewal info and its transaction verify.

## Verifying the Install with AppTransaction

```swift
func checkInstall() async -> Date? {
    do {
        switch try await AppTransaction.shared {
        case .verified(let install):
            _ = install.originalAppVersion
            return install.originalPurchaseDate
        case .unverified:
            return nil
        }
    } catch {
        return nil
    }
}
```

With `.verified`, `originalAppVersion` and `originalPurchaseDate` tell you
whether this user bought the app before a switch to subscriptions, so you can
grandfather them. `.unverified` may mean a tampered install: restrict
features. The `catch` covers failing to fetch the app transaction at all.

## Store View Callbacks

```swift
struct TrackedStore: View {
    let ids: [String]
    let accountID: UUID
    let log: @Sendable (String) -> Void

    var body: some View {
        StoreView(ids: ids)
            .inAppPurchaseOptions { _ in [.appAccountToken(accountID)] }
            .onInAppPurchaseStart { product in log("purchase_started \(product.id)") }
            .onInAppPurchaseCompletion { _, outcome in await settle(outcome) }
    }
}
```

`.onInAppPurchaseStart` suits analytics, `.onInAppPurchaseCompletion` does
verify, deliver, finish, and `.inAppPurchaseOptions` supplies options per
product.

## Ten Mistakes, Fixed

### 1. No listener at launch

Wrong: an `App` with an empty initializer never hears about renewals, refunds
or Ask to Buy approvals.

Right: start `Transaction.updates` in `init()`, as in
[Launch-Time Transaction Listener](#launch-time-transaction-listener).

### 2. Never finishing

Wrong: unlocking and returning. The transaction comes back in the unfinished
queue on every launch.

Right: record the delivery with a call that can fail, then finish. If the
record fails, leave the transaction unfinished so it is offered again.

```swift
func complete(_ receipt: StoreKit.Transaction, ledger: PurchaseLedger) async throws {
    try await ledger.record(receipt.id, product: receipt.productID)
    await receipt.finish()
}
```

### 3. Trusting unverified data

Wrong: `check.unsafePayloadValue` hands you the payload whether or not the
signature holds; that is a security hole.

Right: `let receipt = try verified(check)`.

### 4. Original IAP API in new code

Avoid: `SKProductsRequest(productIdentifiers:)` and
`SKPaymentQueue.default().add(payment)`.

Prefer: `Product.products(for:)` and `product.purchase()`.

### 5. Ignoring revocation

Wrong: inserting `productID` for every verified transaction also grants
refunded purchases.

Right: require `revocationDate == nil` as well.

### 6. Hard-coded prices

Wrong: `Text("Pro for $4.99")` is wrong in every other currency and region.

Right: `Text("\(product.displayName): \(product.displayPrice)")`.

### 7. Dropping pending purchases

Wrong: `default: break` in the result switch silently swallows Ask to Buy.

Right: a `case .pending` that tells the user approval is pending; unlock
only when `Transaction.updates` delivers the approved transaction.

### 8. One entitlement check at launch

Wrong: computing entitlements once and caching them for the session.

Right: recompute from the `Transaction.updates` listener for changes during
the session, and again when the app returns to the foreground, for example
`.task { await store.refresh() }` on the content view.

### 9. No restore control

Wrong: a `SubscriptionStoreView` without restore, which App Review may
reject.

Right: `.storeButton(.visible, for: .restorePurchases)`.

### 10. No policy links

Wrong: a subscription screen with no Terms of Service or Privacy Policy.

Right: both `.subscriptionStorePolicyDestination(url:for:)` calls, one for
`.termsOfService` and one for `.privacyPolicy`.
