How to Implement In-App Purchase in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: StoreKit Updated: May 12, 2026
TL;DR

Use StoreKit 2's StoreView (iOS 17+) to drop in a ready-made paywall, or call Product.products(for:) and product.purchase() for a fully custom UI. Always verify receipts with Transaction.currentEntitlements on launch and listen to Transaction.updates to handle renewals and refunds in real time.

import SwiftUI
import StoreKit

struct QuickPaywall: View {
    private let ids = ["com.yourapp.premium"]

    var body: some View {
        StoreView(ids: ids) {
            Label("Unlock Premium", systemImage: "star.fill")
                .font(.title2.bold())
        }
        .storeButton(.visible, for: .restorePurchases)
        .productViewStyle(.large)
    }
}

#Preview { QuickPaywall() }

Full implementation

The pattern below uses an @Observable StoreManager that loads products, initiates purchases, and reconciles entitlements—then surfaces everything through a SwiftUI environment object. The manager starts a long-running Task on Transaction.updates so that server-side events like subscription renewals, family sharing, and refunds are processed even when your paywall isn't visible. Drop the StoreView-based paywall in for a zero-friction path, or swap in the custom button row for full design control.

import SwiftUI
import StoreKit

// MARK: - Errors
enum StoreError: LocalizedError {
    case failedVerification
    var errorDescription: String? { "Purchase verification failed." }
}

// MARK: - Store Manager
@Observable
final class StoreManager {
    var products: [Product] = []
    var purchasedIDs: Set<String> = []
    var isLoading = false

    private let productIDs: [String]
    private var transactionListener: Task<Void, Error>?

    init(productIDs: [String]) {
        self.productIDs = productIDs
        transactionListener = listenForTransactions()
        Task { await refresh() }
    }

    deinit { transactionListener?.cancel() }

    // Load products from App Store Connect (or .storekit config in debug)
    func refresh() async {
        isLoading = true
        defer { isLoading = false }
        products = (try? await Product.products(for: productIDs)) ?? []
        await updateEntitlements()
    }

    // Initiate a purchase and finish the transaction on success
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updateEntitlements()
            await transaction.finish()
        case .userCancelled:
            break         // user tapped cancel — not an error
        case .pending:
            break         // Ask to Buy / deferred — handle via updates listener
        @unknown default:
            break
        }
    }

    // Restore: iterate current entitlements (no network call needed in SK2)
    func restorePurchases() async {
        await updateEntitlements()
    }

    func isPurchased(_ productID: String) -> Bool {
        purchasedIDs.contains(productID)
    }

    // MARK: Private helpers

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified: throw StoreError.failedVerification
        case .verified(let value): return value
        }
    }

    @MainActor
    private func updateEntitlements() async {
        var ids: Set<String> = []
        for await result in Transaction.currentEntitlements {
            guard let transaction = try? result.payloadValue,
                  transaction.revocationDate == nil else { continue }
            ids.insert(transaction.productID)
        }
        purchasedIDs = ids
    }

    private func listenForTransactions() -> Task<Void, Error> {
        Task.detached(priority: .background) {
            for await result in Transaction.updates {
                if let transaction = try? result.payloadValue {
                    await self.updateEntitlements()
                    await transaction.finish()
                }
            }
        }
    }
}

// MARK: - Custom Paywall View
struct PaywallView: View {
    @State private var store = StoreManager(productIDs: [
        "com.yourapp.premium_lifetime",
        "com.yourapp.pro_monthly"
    ])
    @State private var error: StoreError?
    @State private var showError = false

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    // Header
                    VStack(spacing: 8) {
                        Image(systemName: "star.circle.fill")
                            .font(.system(size: 64))
                            .foregroundStyle(.yellow)
                            .accessibilityHidden(true)
                        Text("Unlock Premium")
                            .font(.largeTitle.bold())
                        Text("Everything you need to ship faster.")
                            .foregroundStyle(.secondary)
                    }
                    .padding(.top, 32)

                    // Product list
                    if store.isLoading {
                        ProgressView()
                    } else {
                        VStack(spacing: 12) {
                            ForEach(store.products) { product in
                                ProductRowView(
                                    product: product,
                                    isPurchased: store.isPurchased(product.id)
                                ) {
                                    await buy(product)
                                }
                            }
                        }
                        .padding(.horizontal)
                    }

                    // Restore link
                    Button("Restore Purchases") {
                        Task { await store.restorePurchases() }
                    }
                    .font(.footnote)
                    .foregroundStyle(.secondary)
                }
            }
            .navigationTitle("Upgrade")
            .navigationBarTitleDisplayMode(.inline)
            .alert("Purchase failed", isPresented: $showError) {
                Button("OK", role: .cancel) {}
            } message: {
                Text(error?.localizedDescription ?? "Unknown error.")
            }
        }
        .task { await store.refresh() }
    }

    private func buy(_ product: Product) async {
        do {
            try await store.purchase(product)
        } catch let e as StoreError {
            error = e; showError = true
        } catch {}
    }
}

// MARK: - Product Row
struct ProductRowView: View {
    let product: Product
    let isPurchased: Bool
    let action: () async -> Void

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(product.displayName)
                    .font(.headline)
                Text(product.description)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            if isPurchased {
                Image(systemName: "checkmark.seal.fill")
                    .foregroundStyle(.green)
                    .accessibilityLabel("Purchased")
            } else {
                Button {
                    Task { await action() }
                } label: {
                    Text(product.displayPrice)
                        .font(.subheadline.bold())
                        .padding(.horizontal, 14)
                        .padding(.vertical, 8)
                        .background(.blue)
                        .foregroundStyle(.white)
                        .clipShape(Capsule())
                }
                .accessibilityLabel("Buy \(product.displayName) for \(product.displayPrice)")
            }
        }
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
    }
}

#Preview {
    PaywallView()
}

How it works

  1. @Observable StoreManager initializer — On init, the manager immediately spawns a detached background Task calling listenForTransactions() and an async task to run refresh(). This ensures products are loaded and the transaction listener is active before the user sees any UI.
  2. Product.products(for:) — This static async method fetches localised product metadata (price, description, display name) from the App Store or, during development, from a .storekit configuration file without any network credential. Always pass the full reverse-DNS product IDs you registered in App Store Connect.
  3. product.purchase() + checkVerified — The purchase() method returns a Product.PurchaseResult. The .success case contains a VerificationResult<Transaction>; calling checkVerified confirms Apple's cryptographic signature before you unlock content. Always call transaction.finish() afterwards—this removes the transaction from the queue so it isn't re-processed.
  4. Transaction.currentEntitlements — An AsyncSequence that yields every active, non-revoked transaction for the current user. Iterating it in updateEntitlements() rebuilds purchasedIDs on launch and after any purchase or restore. Because SK2 stores the signed JWS locally, this requires no network call.
  5. Transaction.updates listener — This AsyncSequence fires for server-side events: subscription renewals, refunds, Ask to Buy approvals, and family-sharing grants. Running it as a detached background task for the lifetime of the app guarantees your entitlements stay accurate without polling.

Variants

Zero-code paywall with StoreView (iOS 17)

iOS 17 ships a first-party StoreView that renders product cards, handles purchase flow, and shows restore automatically. Use it when you want something on screen fast and don't need pixel-perfect control.

import SwiftUI
import StoreKit

struct AutoPaywall: View {
    private let ids = [
        "com.yourapp.premium_lifetime",
        "com.yourapp.pro_monthly"
    ]

    var body: some View {
        SubscriptionStoreView(productIDs: ids) {
            VStack(spacing: 8) {
                Image(systemName: "sparkles")
                    .font(.largeTitle)
                Text("Go Pro")
                    .font(.title.bold())
                Text("Unlimited exports, themes & priority sync.")
                    .multilineTextAlignment(.center)
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
        .subscriptionStoreControlStyle(.prominentPicker)
        .subscriptionStoreButtonLabel(.multiline)
        .storeButton(.visible, for: .restorePurchases)
        .tint(.indigo)
    }
}

#Preview { AutoPaywall() }

Gating a feature inside a view

Rather than a full paywall sheet, you can gate individual views inline. Check store.isPurchased("com.yourapp.premium_lifetime") from the environment and conditionally show either the feature or an upgrade prompt. Pair with a .sheet(isPresented:) that presents PaywallView when the user taps the locked element. This approach keeps the paywall contextual and conversion rates typically improve because the user already understands the value before seeing the price.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement in-app purchase in SwiftUI for iOS 17+.
Use StoreKit 2: Product.products(for:), product.purchase(),
Transaction.currentEntitlements, Transaction.updates.
Create an @Observable StoreManager that loads products,
handles purchases with verified receipt checking, and
restores entitlements on launch.
Make it accessible (VoiceOver labels on price buttons).
Include a StoreView-based paywall variant and a custom UI variant.
Add a #Preview with a .storekit configuration mock.

In Soarias, paste this prompt into the Build phase after your screens are scaffolded — the agent will wire up the StoreKit configuration file, the manager, and the paywall sheet in one shot, leaving you to drop in your real product IDs from App Store Connect.

Related

FAQ

Does this work on iOS 16?

StoreView and SubscriptionStoreView are iOS 17+ only — wrap them in an #if swift(>=5.9) availability check or require iOS 17 as your deployment target. The underlying StoreManager logic (Product.products(for:), product.purchase(), Transaction.currentEntitlements) is StoreKit 2 and works from iOS 15+, so you can drop the SwiftUI paywall views and keep the manager on iOS 16 if needed.

How do I handle subscriptions vs. one-time purchases differently?

Check product.type: .autoRenewable for subscriptions, .nonConsumable for lifetime unlocks, .consumable for credit packs. For subscriptions, use product.subscription?.status (async) to inspect renewal state and expiration date. Consumables must be delivered and finish()-ed immediately—they don't appear in currentEntitlements after being finished, so track delivery in your own persistence layer (SwiftData or UserDefaults).

What's the UIKit equivalent?

In UIKit you use SKProductsRequest / SKPaymentQueue (StoreKit 1), which requires implementing SKPaymentTransactionObserver, manually validating receipts, and handling threading yourself. StoreKit 2's async/await API is significantly simpler and replaces all of that. If you're maintaining an older UIKit codebase, you can adopt StoreKit 2 incrementally—Product and Transaction are plain Swift types that work in any UIKit view controller without SwiftUI.

Last reviewed: 2026-05-12 by the Soarias team.