Claude
Skills
Sign in
Back

storekit

Included with Lifetime
$97 forever

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.

Code Review

What this skill does


# StoreKit 2 In-App Purchases and Subscriptions

Implement in-app purchases, subscriptions, paywalls, and StoreKit testing using
StoreKit 2 on iOS 26+. Use the modern Swift-based `Product`, `Transaction`,
`PurchaseAction`, `StoreView`, and `SubscriptionStoreView` APIs. Avoid original
In-App Purchase APIs (`SKProduct`, `SKPaymentQueue`) unless legacy OS support
requires them.

When reviewing StoreKit code, explicitly separate "preferred SwiftUI path" from
"invalid API": `PurchaseAction` is the preferred custom SwiftUI button path, but
direct `product.purchase(options:)` is still valid for lower-level custom
StoreKit flows.

## Contents

- [Implementation Review Minimums](#implementation-review-minimums)
- [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)

## Implementation Review Minimums

When reviewing a paywall, purchase manager, or entitlement gate, include these
points explicitly:

- Standard SwiftUI paywalls should prefer `StoreView`, `ProductView`, or
  `SubscriptionStoreView`; custom SwiftUI buy buttons should prefer
  `PurchaseAction`; direct `product.purchase(options:)` is valid for
  lower-level custom StoreKit flows.
- `Transaction.updates` must start at app launch because it catches purchases
  from other devices, Family Sharing changes, renewals, Ask to Buy approvals,
  refunds, revocations, and unfinished transactions.
- Include this entitlement-scope sentence verbatim when reviewing
  `Transaction.currentEntitlements`: "It covers non-consumables, active or
  grace-period auto-renewable subscriptions, and non-renewing subscriptions; it
  does not include consumable purchase or delivery history."
- Verify every `VerificationResult` before granting access. Deliver or persist
  the entitlement first, then call `transaction.finish()`.
- Pending purchases and user cancellations never unlock content; pending Ask to
  Buy approvals unlock only after a verified transaction arrives through the
  launch-time listener.
- Exclude refunded or revoked transactions from active entitlement state and
  re-check entitlements when refunds or revocations arrive through
  `Transaction.updates`.
- Provide a visible restore purchases path and Terms of Service / Privacy
  Policy links on subscription paywalls.

## 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

Prefer StoreKit views for standard paywalls because they initiate purchases,
restore purchases, and display policy controls. For custom SwiftUI purchase
buttons, prefer `PurchaseAction` from the environment. Use direct
`product.purchase(options:)` only for lower-level custom flows, and use
`purchase(confirmIn:options:)` for UIKit or AppKit confirmation. Always handle
every `PurchaseResult`, verify before access, deliver durably, then finish.

Review wording: do not call `product.purchase(options:)` inherently wrong. Say
"prefer `PurchaseAction` for SwiftUI buttons; keep `product.purchase(options:)`
for lower-level custom flows that need direct StoreKit control."

```swift
@Environment(\.purchase) private var purchase

func purchaseProduct(_ product: Product) async throws {
    let result = try await purchase(product, 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: show pending UI, no unlock yet.
        showPendingApprovalMessage()
    @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, not when a paywall appears. Catches purchases from other
devices, Family Sharing changes, renewals, Ask to Buy approvals, refunds,
revocations, and unfinished transactions Apple emits once immediately after
launch. Keep the task retained for the app lifetime.

In implementation reviews, name the launch-time coverage explicitly: purchases
made on other devices, Family Sharing changes, subscription renewals, Ask to Buy
approvals, refunds, revocations, and unfinished transactions.

```swift
@main
struct MyApp: App {
    private let transactionListener: Task<Void, Never>

    init() {
        transactionListener = Self.listenForTransactions()
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }

    static func listenForTransactions() -> Task<Void, Never> {
        Task(priority: .background) {
            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-consumables, active or grace
period auto-renewable subscriptions, and non-renewing subscriptions. It excludes
consumables and consumable delivery history; track consumable fulfillment in
your own app or server ledger. It also excludes refunded or revoked
transactions. Use `Transaction.unfinished` for unfinished consumables and
recovery sweeps. Always check `revocationDate` when processing transactions.

In reviews, include this sentence verbatim: "Transaction.currentEntitlements
covers non-consumables, active or grace-period auto-renewable subscriptions, and
non-renewing subscriptions; it does not include consumable purchase or delivery
history." Do not replace this with only a code sample or a revocation check.

```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

Files: 4
Size: 50.9 KB
Complexity: 50/100
Category: Code Review

Related in Code Review