StoreKit 2: In-App Purchases and Subscriptions with Modern Swift APIs


The original StoreKit API shipped with iOS 3. It worked through delegate callbacks, receipt validation was a pain, and determining whether a user was actually entitled to a product required parsing ASN.1 receipts or maintaining your own server. StoreKit 2, introduced in iOS 15, replaced all of that with async/await APIs, server-signed JWS transactions, and a Transaction.currentEntitlements sequence that tells you exactly what a user owns — no receipt parsing required.

This post covers the full StoreKit 2 purchase lifecycle: fetching products, executing purchases, verifying transactions, checking entitlements, building subscription UIs with SubscriptionStoreView, and the new Advanced Commerce API in iOS 26 for large catalogs. We won’t cover App Store Connect product configuration or server-side receipt validation — those are operational concerns outside the client SDK.

Note: StoreKit 2 requires iOS 15+. The SubscriptionStoreView and SubscriptionOfferView APIs require iOS 17+. The Advanced Commerce API requires iOS 26+. All code in this post uses Swift 6 strict concurrency.

Contents

The Problem

Consider a Pixar film streaming app that offers a monthly subscription and individual film purchases. With the original StoreKit, fetching products and handling purchases looked something like this:

// Original StoreKit — delegate-based, no structured concurrency
final class PixarStoreManager: NSObject, SKProductsRequestDelegate,
                               SKPaymentTransactionObserver {
    var products: [SKProduct] = []

    func fetchProducts() {
        let request = SKProductsRequest(productIdentifiers: [
            "com.pixarstream.monthly",
            "com.pixarstream.film.toystory"
        ])
        request.delegate = self
        request.start()
    }

    func productsRequest(
        _ request: SKProductsRequest,
        didReceive response: SKProductsResponse
    ) {
        products = response.products
    }

    func paymentQueue(
        _ queue: SKPaymentQueue,
        updatedTransactions transactions: [SKPaymentTransaction]
    ) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .purchased:
                // Parse the receipt? Validate on server? Both?
                queue.finishTransaction(transaction)
            case .failed:
                queue.finishTransaction(transaction)
            default:
                break
            }
        }
    }
}

The issues compound in production. You need to manually add and remove the transaction observer. Receipt validation requires either a server roundtrip or local ASN.1 parsing. Determining subscription status means decoding receipt fields and comparing dates. Restoring purchases requires a separate SKPaymentQueue.restoreCompletedTransactions() call. None of this is async/await-compatible, and error handling is scattered across multiple delegate callbacks.

StoreKit 2 collapses all of this into a handful of async calls with typed results.

Fetching Products

Apple Docs: Product — StoreKit

The entry point is Product.products(for:). Pass your product identifiers and you get back an array of Product values — a value type with all the metadata you need for display:

import StoreKit

@Observable @MainActor
final class PixarStore {
    private(set) var films: [Product] = []
    private(set) var subscriptions: [Product] = []
    private(set) var isLoading = false

    private let filmIDs: Set<String> = [
        "com.pixarstream.film.toystory",
        "com.pixarstream.film.findingNemo",
        "com.pixarstream.film.insideOut",
        "com.pixarstream.film.coco"
    ]

    private let subscriptionIDs: Set<String> = [
        "com.pixarstream.monthly",
        "com.pixarstream.annual"
    ]

    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }

        do {
            let allProducts = try await Product.products(
                for: filmIDs.union(subscriptionIDs)
            )

            films = allProducts
                .filter { $0.type == .consumable || $0.type == .nonConsumable }
                .sorted { $0.price < $1.price }

            subscriptions = allProducts
                .filter { $0.type == .autoRenewable }
                .sorted { $0.price < $1.price }
        } catch {
            // Product fetch failures are usually network-related.
            // Log and let the UI show an empty state.
            print("Failed to fetch products: \(error)")
        }
    }
}

Product exposes displayName, description, displayPrice (already formatted for the user’s locale), price (a raw Decimal), and type (.consumable, .nonConsumable, .autoRenewable, .nonRenewable). You never need to format currency manually.

Tip: Use a StoreKit Configuration File in Xcode for local testing. It lets you define products without an App Store Connect account and test the full purchase flow in the simulator, including subscription renewals on an accelerated timeline.

The Purchase Flow

Apple Docs: Product.purchase(options:) — StoreKit

Purchasing a product is a single async call that returns a Product.PurchaseResult:

extension PixarStore {
    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerification(verification)
            await transaction.finish()
            await refreshEntitlements()
            return transaction

        case .userCancelled:
            return nil

        case .pending:
            // Transaction requires approval (Ask to Buy, SCA, etc.)
            return nil

        @unknown default:
            return nil
        }
    }

    private func checkVerification<T>(
        _ result: VerificationResult<T>
    ) throws -> T {
        switch result {
        case .verified(let safe):
            return safe
        case .unverified(_, let error):
            throw StoreError.verificationFailed(error)
        }
    }
}

enum StoreError: LocalizedError {
    case verificationFailed(Error)

    var errorDescription: String? {
        switch self {
        case .verificationFailed(let underlying):
            return "Transaction verification failed: \(underlying.localizedDescription)"
        }
    }
}

Three things to note here. First, the purchase result wraps the transaction in a VerificationResult. StoreKit 2 automatically verifies the JWS signature on the device — you get either .verified (the transaction is cryptographically valid) or .unverified (something is wrong). Second, you must call transaction.finish() to acknowledge the transaction. Unfinished transactions will be re-delivered on the next app launch. Third, the .pending case handles Ask to Buy (parental approval) and Strong Customer Authentication (EU payment regulation). Your UI should show an appropriate “waiting for approval” state.

Purchase Options

You can pass options to customize the purchase. The most common is appAccountToken, which lets you associate a purchase with a specific user account in your backend:

func purchase(
    _ product: Product,
    for userID: UUID
) async throws -> Transaction? {
    let result = try await product.purchase(options: [
        .appAccountToken(userID)
    ])
    // Handle result as shown above
}

This token persists through the transaction’s lifetime and appears in server-to-server notifications — invaluable for matching App Store transactions to your user database.

Transaction Verification and Listening

Apple Docs: Transaction — StoreKit

StoreKit 2 transactions are JWS (JSON Web Signature) payloads signed by Apple. The framework verifies them on-device automatically. You should still handle the .unverified case defensively — it can occur if the device clock is significantly wrong or if the binary has been tampered with.

The critical pattern for production apps is listening for transaction updates on app launch. Transactions can arrive at any time: a pending purchase gets approved, a subscription renews in the background, or a refund is processed:

extension PixarStore {
    /// Call once on app launch — typically in the App init or a task modifier.
    func listenForTransactions() -> Task<Void, Never> {
        Task.detached { [weak self] in
            for await result in Transaction.updates {
                guard let self else { return }
                do {
                    let transaction = try self.checkVerification(result)
                    await transaction.finish()
                    await self.refreshEntitlements()
                } catch {
                    // Log verification failures but don't crash.
                    print("Transaction update verification failed: \(error)")
                }
            }
        }
    }
}

Warning: Transaction.updates only delivers transactions that arrive after your listener starts. It does not replay historical transactions. For initial state on launch, use Transaction.currentEntitlements (see next section).

Store the returned Task so you can cancel it if needed — though in most apps, you want this listener running for the entire app lifecycle.

Entitlement Checking

Apple Docs: Transaction.currentEntitlements — StoreKit

Entitlement checking is where StoreKit 2 truly shines compared to its predecessor. Transaction.currentEntitlements is an AsyncSequence that yields every product the user is currently entitled to — active subscriptions, non-consumable purchases, and non-renewing subscriptions that haven’t expired:

extension PixarStore {
    @Observable @MainActor
    final class Entitlements {
        var ownedFilmIDs: Set<String> = []
        var activeSubscription: Transaction?
        var subscriptionTier: SubscriptionTier = .none
    }

    enum SubscriptionTier: Comparable {
        case none
        case monthly
        case annual
    }

    func refreshEntitlements() async {
        var ownedFilms: Set<String> = []
        var latestSubscription: Transaction?

        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                ownedFilms.insert(transaction.productID)

            case .autoRenewable:
                if let latest = latestSubscription {
                    if transaction.purchaseDate > latest.purchaseDate {
                        latestSubscription = transaction
                    }
                } else {
                    latestSubscription = transaction
                }

            default:
                break
            }
        }

        entitlements.ownedFilmIDs = ownedFilms
        entitlements.activeSubscription = latestSubscription
        entitlements.subscriptionTier = tierFor(
            latestSubscription?.productID
        )
    }

    private func tierFor(_ productID: String?) -> SubscriptionTier {
        switch productID {
        case "com.pixarstream.annual": return .annual
        case "com.pixarstream.monthly": return .monthly
        default: return .none
        }
    }
}

Call refreshEntitlements() on app launch (after your transaction listener is set up) and after every successful purchase. The combination of Transaction.currentEntitlements for initial state and Transaction.updates for real-time changes gives you a complete entitlement picture without touching a receipt.

Checking Entitlements in Views

With the Entitlements object exposed as an @Observable property, gating content in SwiftUI is straightforward:

struct FilmPlayerView: View {
    let film: PixarFilm
    var store: PixarStore

    var body: some View {
        if store.entitlements.ownedFilmIDs.contains(film.productID)
            || store.entitlements.subscriptionTier >= .monthly {
            VideoPlayerView(url: film.streamURL)
        } else {
            PurchasePromptView(film: film, store: store)
        }
    }
}

Subscription Management

Apple Docs: Product.SubscriptionInfo — StoreKit

For auto-renewable subscriptions, Product.SubscriptionInfo provides subscription-specific metadata. You access it through product.subscription:

func loadSubscriptionStatus() async {
    guard let subscription = subscriptions.first,
          let info = subscription.subscription else { return }

    // Subscription group ID — all products in the same group
    let groupID = info.subscriptionGroupID

    // Check current subscription status for this group
    guard let statuses = try? await info.status else { return }

    for status in statuses {
        guard case .verified(let renewalInfo) = status.renewalInfo,
              case .verified(let transaction) = status.transaction
        else { continue }

        let isActive = status.state == .subscribed
            || status.state == .inGracePeriod
        let willRenew = renewalInfo.willAutoRenew
        let expirationDate = transaction.expirationDate

        print("Active: \(isActive), renews: \(willRenew)")
        if let expiration = expirationDate {
            print("Expires: \(expiration)")
        }
    }
}

The status.state enum covers every subscription lifecycle state: .subscribed, .expired, .inBillingRetryPeriod, .inGracePeriod, and .revoked. This is dramatically simpler than decoding these states from a receipt.

SubscriptionStoreView and Offer Views

Apple Docs: SubscriptionStoreView — StoreKit

iOS 17 introduced SubscriptionStoreView — a pre-built, customizable paywall view that handles product display, purchase flow, and even subscription management UI:

import StoreKit

struct PixarPaywallView: View {
    let groupID: String // Your subscription group ID

    var body: some View {
        SubscriptionStoreView(groupID: groupID) {
            VStack(spacing: 16) {
                Image(systemName: "film.stack")
                    .font(.system(size: 60))
                    .foregroundStyle(.blue)

                Text("Unlock the Pixar Vault")
                    .font(.title.bold())

                Text("Stream every Pixar film, from Toy Story to the latest release.")
                    .font(.body)
                    .multilineTextAlignment(.center)
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
        .subscriptionStoreControlStyle(.prominentPicker)
        .subscriptionStoreButtonLabel(.multiline)
        .storeButton(.visible, for: .restorePurchases)
    }
}

SubscriptionStoreView reads product information and pricing directly from App Store Connect. It handles localization, pricing display, the purchase sheet, and error states. You provide the marketing content — the header area — and the system handles the rest.

SubscriptionOfferView

For win-back and promotional offers, use SubscriptionOfferView to present targeted offers to eligible users:

@available(iOS 18, *)
struct PixarWinBackView: View {
    let groupID: String

    var body: some View {
        SubscriptionStoreView(groupID: groupID)
            .winBackOffer { offer in
                VStack {
                    Text("We miss you at the Pixar Vault!")
                        .font(.headline)
                    Text(offer.displayPrice)
                        .font(.title2.bold())
                    Text("for \(offer.period.debugDescription)")
                        .font(.subheadline)
                }
            }
    }
}

Tip: Win-back offers are configured in App Store Connect. The winBackOffer modifier only appears when the system determines the user is eligible — you don’t need to check eligibility yourself.

Advanced Commerce API (iOS 26)

Apple Docs: ProductStore — StoreKit

iOS 26 introduces the Advanced Commerce API for apps with large or dynamic product catalogs — think a Pixar collectibles marketplace with hundreds of digital items that change seasonally. The traditional approach of hardcoding product identifiers breaks down at scale.

The new ProductStore protocol and CommerceSession allow you to define products dynamically and handle complex purchase flows that don’t map cleanly to the standard App Store product model:

@available(iOS 26, *)
struct PixarCollectibleStore {
    /// Fetch products from your own catalog service,
    /// then resolve them through the Advanced Commerce API.
    func fetchCollectibles() async throws -> [Product] {
        let catalogItems = try await PixarCatalogService.fetchCurrentSeason()

        let productIDs = Set(catalogItems.map(\.appStoreProductID))
        let products = try await Product.products(for: productIDs)

        return products
    }

    /// Use CommerceSession for multi-step purchase flows
    func purchaseBundle(items: [PixarCollectible]) async throws {
        for item in items {
            let products = try await Product.products(
                for: [item.appStoreProductID]
            )
            guard let product = products.first else { continue }

            let result = try await product.purchase(options: [
                .appAccountToken(item.ownerAccountID)
            ])

            if case .success(let verification) = result,
               case .verified(let transaction) = verification {
                await transaction.finish()
                try await PixarCatalogService.confirmPurchase(
                    collectibleID: item.id,
                    transactionID: transaction.id
                )
            }
        }
    }
}

The Advanced Commerce API is designed for apps whose catalogs are too large or too dynamic for App Store Connect’s product management UI. If your app has fewer than a few hundred products and they don’t change frequently, standard StoreKit 2 is sufficient. The Advanced Commerce API adds complexity that’s only justified at scale.

Note: The Advanced Commerce API requires an entitlement from Apple. You must apply for access through App Store Connect. This is not a general-availability API — it targets specific use cases like digital marketplaces and large-scale content platforms.

Performance Considerations

Product fetch latency. Product.products(for:) makes a network call to the App Store. On a cold launch, this can take 500ms-2s depending on network conditions. Fetch products early — ideally in a background task triggered by .task on your root view — and cache the results in your @Observable store. Products don’t change mid-session, so a single fetch per app launch is sufficient.

Transaction iteration. Transaction.currentEntitlements iterates over all verified transactions on device. For most apps (single-digit products), this is near-instant. If your app has hundreds of products, the iteration itself is negligible, but the verification step for each transaction involves JWS signature checking. Profile with Instruments if you see latency on launch.

Transaction.updates memory. The async sequence holds a reference to the StoreKit subsystem. The Task you create for listening should live for the app’s lifetime — store it in your App struct or a long-lived manager. Creating and canceling multiple listeners is wasteful.

Subscription status checks. Product.SubscriptionInfo.status makes a server call. Don’t poll it repeatedly. Check on launch and after relevant transaction events. Cache the result in your observable state.

// Efficient product loading — cache and don't refetch
@Observable @MainActor
final class PixarStoreCached {
    private var hasLoadedProducts = false

    func loadProductsIfNeeded() async {
        guard !hasLoadedProducts else { return }
        await loadProducts()
        hasLoadedProducts = true
    }
}

Apple Docs: Transaction.updates — StoreKit

When to Use (and When Not To)

ScenarioRecommendation
New app targeting iOS 15+Use StoreKit 2 exclusively. The async APIs are cleaner and transactions are auto-verified.
Existing app with original StoreKitMigrate incrementally. StoreKit 1 and 2 coexist. Use StoreKit 2 for new flows.
Subscription paywall UI (iOS 17+)Use SubscriptionStoreView. Don’t rebuild the purchase sheet yourself.
Catalog with 500+ dynamic productsInvestigate the Advanced Commerce API (iOS 26).
Server-side entitlement validationSend originalTransactionId to your server. Verify via the App Store Server API.
Consumable products with high volumecurrentEntitlements excludes finished consumables. Track balances in your backend.

Summary

  • Product.products(for:) replaces SKProductsRequest with a single async call that returns typed Product values with locale-formatted pricing.
  • product.purchase() returns a PurchaseResult with .success, .userCancelled, and .pending cases — no delegate callbacks, no transaction observer boilerplate.
  • All transactions are JWS-signed and automatically verified on device. Handle .unverified defensively but expect it to be rare.
  • Transaction.currentEntitlements gives you the complete entitlement picture on launch. Transaction.updates delivers real-time changes. Together they replace receipt parsing entirely.
  • SubscriptionStoreView (iOS 17+) provides a production-ready paywall UI. Customize the marketing header and let the system handle pricing, localization, and purchase flow.
  • The Advanced Commerce API (iOS 26) targets apps with large, dynamic catalogs that exceed standard App Store Connect product management.

Ready to build a complete paywall from scratch? Head to Build an In-App Purchase Paywall with StoreKit 2 for a step-by-step tutorial that wires up products, purchases, and entitlement gating in a real app.