In-App Purchases with StoreKit 2 β€” A Complete Guide

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.

IAP Types

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 2 vs StoreKit 1

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.

App Store Connect Setup

Before writing any code, configure your products:

  1. Sign the Paid Applications Agreement in App Store Connect.
  2. Navigate to your app β†’ Monetization β†’ In-App Purchases.
  3. Create each product with a reference name, product ID (e.g. com.example.app.premium), price tier, and localization.
  4. For subscriptions, configure subscription groups, duration, and introductory offers.
  5. Generate an In-App Purchase Key under Users and Access β†’ Keys if you plan server-side validation.

Fetching Products

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           // .nonConsumable

Making a Purchase

Purchasing 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.

Listening for 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.

Checking Entitlements

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.

SwiftUI Store Views

StoreKit provides three ready-made SwiftUI views that handle fetching, display, and purchasing automatically. You can use them in as little as one line:

ProductView β€” Single Product

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 β€” Multiple Products

StoreView(ids: [
    "com.example.app.monthly",
    "com.example.app.yearly"
])

The StoreView lays out products vertically and handles individual purchase flows for each.

SubscriptionStoreView β€” Subscription Groups

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.

Testing Purchases

StoreKit provides two testing approaches:

StoreKit Configuration File

  1. File β†’ New β†’ File β†’ StoreKit Configuration File.
  2. Define products in the .storekit file with IDs, prices, and durations.
  3. Set the scheme’s StoreKit Configuration to this file.

Xcode simulates the App Store locally β€” no network calls, no real money. You can test:

  • Standard purchases
  • Ask to Buy
  • Refunds
  • Subscription renewals at accelerated rates (1 min = 1 hour, 1 min = 1 day, etc.)
// In simulator, StoreKit uses the config file automatically
// when your scheme points to it. No code changes needed.

App Store Sandbox

For end-to-end testing with Apple’s servers:

  1. Create a Sandbox Tester in App Store Connect.
  2. Build and run on a device or simulator signed in with the sandbox account.
  3. Test the full flow β€” real server calls, but no real charges.

Sandbox uses accelerated subscription renewal (6x–12x real time).

Error Handling

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
    }

Restoring Purchases

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.

Receipt Validation

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:

  1. Send the transaction.jwsRepresentation string to your server.
  2. Validate the signed transaction information with Apple’s App Store Server Library or your own certificate-chain validation.
  3. Prefer signed StoreKit 2 transaction JWS for new code; use legacy receipts only when supporting StoreKit 1 compatibility or older server flows.

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
}

Putting It All Together

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

Subscription-Specific Considerations

Auto-renewable subscriptions add complexity beyond consumables and non-consumables:

  • Grace period: When billing fails, iOS attempts recovery. Listen for Transaction.updates to receive the billing retry transaction.
  • Win-back offers (iOS 18+): You can offer discounted reinstatement pricing to lapsed subscribers.
  • Promotional offers: Generate offer signatures server-side and apply them via product.purchase(options:).
  • Subscription status: Use 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
}