BS
BleepingSwift
Published on
8 min read

> StoreKit 2 for In-App Purchases and Subscriptions

StoreKit 2, introduced at WWDC 2021, completely rethought how iOS apps handle in-app purchases. The original StoreKit framework relied on delegates, observer patterns, and manual receipt validation. StoreKit 2 replaces all of that with async/await APIs, automatic transaction verification through JWS signatures, and a much cleaner data model. If you're starting a new project or modernizing an existing one, StoreKit 2 is the way to go.

Loading Products

Everything starts with Product. You define product identifiers in App Store Connect, then fetch them at runtime:

import StoreKit

func fetchProducts() async throws -> [Product] {
    let productIDs = [
        "com.myapp.premium",
        "com.myapp.monthly",
        "com.myapp.yearly"
    ]

    return try await Product.products(for: productIDs)
}

Each Product carries everything you need to display it: displayName, description, displayPrice (already localized for the user's region), and the numeric price. The type property tells you whether it's a .consumable, .nonConsumable, .autoRenewable, or .nonRenewable product.

Making a Purchase

Purchasing a product is a single async call. The result tells you exactly what happened:

import StoreKit

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 payment
        return nil

    @unknown default:
        return nil
    }
}

The .success case wraps the transaction in a VerificationResult. StoreKit automatically validates the JWS signature from Apple's servers, so calling payloadValue either returns a verified transaction or throws if the signature doesn't check out. No more manual receipt validation.

Calling transaction.finish() is important. It tells the system you've delivered the content. If you don't finish a transaction, StoreKit will keep delivering it through Transaction.updates until you do.

Listening for Transaction Updates

Not every transaction happens while the user is actively tapping a "Buy" button. Subscriptions renew in the background, Family Sharing can grant or revoke access, Ask to Buy requests get approved later, and users can request refunds. You need to listen for all of these.

Start a Transaction.updates listener when your app launches:

import StoreKit

@MainActor
class StoreManager: ObservableObject {
    @Published private(set) var purchasedProductIDs: Set<String> = []

    private var updatesTask: Task<Void, Never>?

    init() {
        updatesTask = Task {
            for await update in Transaction.updates {
                if let transaction = try? update.payloadValue {
                    await self.refreshPurchasedProducts()
                    await transaction.finish()
                }
            }
        }

        Task {
            await refreshPurchasedProducts()
        }
    }

    deinit {
        updatesTask?.cancel()
    }

    func refreshPurchasedProducts() async {
        var purchased: Set<String> = []

        for await entitlement in Transaction.currentEntitlements {
            if let transaction = try? entitlement.payloadValue {
                purchased.insert(transaction.productID)
            }
        }

        self.purchasedProductIDs = purchased
    }
}

Transaction.currentEntitlements is your source of truth for what the user currently owns. It includes active subscriptions and non-consumable purchases that haven't been refunded. Consumables don't appear here since they're meant to be used up.

Checking Subscription Status

For auto-renewable subscriptions, you'll often need more detail than just "active or not." The subscription status API gives you the full picture:

import StoreKit

func checkSubscriptionStatus(for product: Product) async throws -> Bool {
    guard let statuses = try await product.subscription?.status else {
        return false
    }

    for status in statuses {
        switch status.state {
        case .subscribed, .inGracePeriod:
            return true
        case .expired, .revoked:
            continue
        case .inBillingRetryPeriod:
            // Payment failed but Apple is retrying.
            // You could still grant access here depending on your policy.
            continue
        default:
            continue
        }
    }

    return false
}

The RenewalState tells you exactly where the subscription stands. .inGracePeriod means billing failed but you've enabled a grace period in App Store Connect, so the user should still have access. .inBillingRetryPeriod means Apple is still trying to charge the user. Whether you grant access during retry is your call.

You can also dig into status.renewalInfo to check autoRenewalStatus (whether the user has turned off auto-renew), gracePeriodExpirationDate, and on iOS 17+, nextRenewalDate.

SwiftUI StoreKit Views

Starting with iOS 17, Apple added SwiftUI views that handle product display and purchasing out of the box. These save a lot of work if you don't need a fully custom UI.

SubscriptionStoreView is the big one for subscriptions. You pass it a subscription group ID and it builds the entire merchandising experience:

import SwiftUI
import StoreKit

struct PaywallView: View {
    var body: some View {
        SubscriptionStoreView(groupID: "598392E1") {
            VStack(spacing: 12) {
                Image(systemName: "star.fill")
                    .font(.system(size: 48))
                    .foregroundStyle(.yellow)
                Text("Unlock Premium")
                    .font(.title.bold())
                Text("Get access to all features with a subscription.")
                    .multilineTextAlignment(.center)
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
        .subscriptionStoreControlStyle(.buttons)
        .subscriptionStoreButtonLabel(.multiline)
    }
}

The group ID comes from App Store Connect (or your StoreKit configuration file for testing). The view handles loading products, displaying prices, and processing purchases automatically.

For individual products, there's ProductView and StoreView:

import SwiftUI
import StoreKit

struct StorePageView: View {
    let productIDs = [
        "com.myapp.smalltip",
        "com.myapp.mediumtip",
        "com.myapp.largetip"
    ]

    var body: some View {
        StoreView(ids: productIDs)
            .productViewStyle(.large)
            .storeButton(.visible, for: .restorePurchases)
    }
}

The .storeButton(.visible, for: .restorePurchases) modifier adds a restore purchases button, which Apple still expects to see in your app.

Putting It All Together

Here's a more complete store manager that handles both one-time purchases and subscriptions:

import StoreKit

@MainActor
class StoreManager: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []

    private let productIDs = [
        "com.myapp.premium",
        "com.myapp.monthly",
        "com.myapp.yearly"
    ]

    private var updatesTask: Task<Void, Never>?

    var isPremium: Bool {
        !purchasedProductIDs.isEmpty
    }

    init() {
        updatesTask = Task {
            for await update in Transaction.updates {
                if let transaction = try? update.payloadValue {
                    await refreshPurchasedProducts()
                    await transaction.finish()
                }
            }
        }

        Task {
            await fetchProducts()
            await refreshPurchasedProducts()
        }
    }

    deinit {
        updatesTask?.cancel()
    }

    func fetchProducts() async {
        do {
            products = try await Product.products(for: productIDs)
                .sorted { $0.price < $1.price }
        } catch {
            print("Failed to load products: \(error)")
        }
    }

    func purchase(_ product: Product) async throws -> Bool {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try verification.payloadValue
            await refreshPurchasedProducts()
            await transaction.finish()
            return true

        case .userCancelled, .pending:
            return false

        @unknown default:
            return false
        }
    }

    func refreshPurchasedProducts() async {
        var purchased: Set<String> = []

        for await entitlement in Transaction.currentEntitlements {
            if let transaction = try? entitlement.payloadValue {
                purchased.insert(transaction.productID)
            }
        }

        purchasedProductIDs = purchased
    }

    func restorePurchases() async throws {
        try await AppStore.sync()
        await refreshPurchasedProducts()
    }
}

You'd inject this into your SwiftUI views as an environment object:

import SwiftUI

@main
struct MyApp: App {
    @StateObject private var store = StoreManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}

Testing with StoreKit Configuration Files

You don't need an App Store Connect account to start testing. Xcode's StoreKit configuration files let you define products locally and test the entire purchase flow in the simulator.

  1. Create a new file in Xcode (File > New > File) and choose "StoreKit Configuration File"
  2. Add your products with the same identifiers you plan to use in App Store Connect
  3. Go to your scheme settings (Product > Scheme > Edit Scheme), select Run > Options, and set the StoreKit Configuration to your file

With that in place, all StoreKit calls hit the local configuration instead of Apple's servers. You can test purchases, subscriptions, renewals, cancellations, and refunds without spending real money or waiting for sandbox delays.

The Transaction Manager (Debug > StoreKit > Manage Transactions) lets you view and manipulate transactions while debugging. You can delete transactions to reset state, issue refunds, and for subscriptions, the renewal rate is accelerated so you're not waiting around for monthly cycles.

For automated tests, the StoreKitTest framework gives you programmatic control:

import StoreKitTest
import Testing

@Test func testPurchaseFlow() async throws {
    let session = try SKTestSession(configurationFileNamed: "Store")
    session.clearTransactions()

    let products = try await Product.products(for: ["com.myapp.premium"])
    let product = try #require(products.first)
    let result = try await product.purchase()

    guard case .success(let verification) = result else {
        Issue.record("Expected successful purchase")
        return
    }

    let transaction = try verification.payloadValue
    #expect(transaction.productID == "com.myapp.premium")
    await transaction.finish()
}

What's New in Recent Releases

StoreKit 2 has evolved significantly since its introduction. iOS 17 brought the SwiftUI views covered above, plus useful transaction metadata like storefront and reason (distinguishing .purchase from .renewal).

iOS 18 officially deprecated the original StoreKit framework and added win-back offers for re-engaging lapsed subscribers. It also introduced new SubscriptionStoreView control styles like .compactPicker and .pagedPicker for different subscription presentation layouts.

Most recently, iOS 26 added SubscriptionOfferView for merchandising upgrade, downgrade, and crossgrade offers within a subscription group, plus expanded offer code support to consumables and non-consumables.

For more details on these updates, check Apple's StoreKit documentation and the yearly WWDC sessions on what's new in StoreKit.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.