Lab 7.3 — StoreKit 2 IAP

Goal: Build a complete IAP paywall in SwiftUI: a non-consumable “Remove Ads,” monthly and yearly Pro subscriptions, an intro free trial on yearly, a restore-purchases button, and listening to Transaction.updates. Test everything against a local StoreKit Configuration file with no Apple Developer account required for the basics.

Time: 90–150 minutes.

Prereqs: Xcode 16+, iOS 18+ deployment target.

Setup

  1. New Xcode project → App → SwiftUI → name StoreKitIAPLab.
  2. File → New → File → App → StoreKit Configuration File → name Products.storekit → check “Sync with App Store Connect: No” (local-only).
  3. In Products.storekit, add three products:
    • Non-Consumable — ID com.example.iaplab.removeAds, display “Remove Ads”, price $4.99.
    • Auto-Renewable Subscription — create subscription group Pro:
      • ID com.example.iaplab.pro.monthly, “Pro Monthly”, $9.99 / month.
      • ID com.example.iaplab.pro.yearly, “Pro Yearly”, $79.99 / year. Add an Introductory Offer: free trial, 1 week, pay-as-you-go.
  4. Edit the scheme: Run → Options → StoreKit Configuration → select Products.storekit.

Build

Store actor

Store.swift:

import StoreKit
import Observation

enum ProductID: String, CaseIterable {
    case removeAds = "com.example.iaplab.removeAds"
    case proMonthly = "com.example.iaplab.pro.monthly"
    case proYearly = "com.example.iaplab.pro.yearly"
}

enum StoreError: Error { case unverified, productNotFound }

@Observable
@MainActor
final class Store {
    var products: [Product] = []
    var ownedProductIDs: Set<String> = []
    var subscriptionState: Product.SubscriptionInfo.RenewalState?
    var isLoading = false
    var lastError: String?

    private var updateListener: Task<Void, Never>?

    init() {
        updateListener = listenForTransactions()
        Task {
            await loadProducts()
            await refreshEntitlements()
        }
    }

    deinit { updateListener?.cancel() }

    func loadProducts() async {
        isLoading = true; defer { isLoading = false }
        do {
            let ids = ProductID.allCases.map(\.rawValue)
            products = try await Product.products(for: ids).sorted { $0.price < $1.price }
        } catch {
            lastError = "Load failed: \(error.localizedDescription)"
        }
    }

    func purchase(_ product: Product) async {
        do {
            let result = try await product.purchase()
            switch result {
            case .success(let verification):
                let transaction = try verify(verification)
                await refreshEntitlements()
                await transaction.finish()
            case .userCancelled, .pending: break
            @unknown default: break
            }
        } catch {
            lastError = "Purchase failed: \(error.localizedDescription)"
        }
    }

    func restorePurchases() async {
        do {
            try await AppStore.sync()
            await refreshEntitlements()
        } catch {
            lastError = "Restore failed: \(error.localizedDescription)"
        }
    }

    func refreshEntitlements() async {
        var owned: Set<String> = []
        for await result in Transaction.currentEntitlements {
            if case let .verified(transaction) = result {
                owned.insert(transaction.productID)
            }
        }
        ownedProductIDs = owned

        // Subscription state
        if let proGroup = products.first(where: { $0.subscription != nil })?.subscription {
            let statuses = (try? await proGroup.status) ?? []
            subscriptionState = statuses.first?.state
        }
    }

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

    private func listenForTransactions() -> Task<Void, Never> {
        Task.detached(priority: .background) { [weak self] in
            for await result in Transaction.updates {
                guard let self else { return }
                guard case let .verified(transaction) = result else { continue }
                await self.refreshEntitlements()
                await transaction.finish()
            }
        }
    }
}

Paywall UI

PaywallView.swift:

import SwiftUI
import StoreKit

struct PaywallView: View {
    @Environment(Store.self) private var store

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                header
                if store.isLoading && store.products.isEmpty {
                    ProgressView()
                } else {
                    productList
                }
                Spacer()
                Button("Restore Purchases") {
                    Task { await store.restorePurchases() }
                }
                .font(.callout)

                if let error = store.lastError {
                    Text(error)
                        .font(.caption)
                        .foregroundStyle(.red)
                        .multilineTextAlignment(.center)
                }
            }
            .padding()
            .navigationTitle("Upgrade")
        }
    }

    private var header: some View {
        VStack(spacing: 8) {
            Image(systemName: "sparkles")
                .font(.system(size: 56))
                .foregroundStyle(.tint)
            Text("Unlock everything")
                .font(.title.bold())
            Text("Remove ads forever, or go Pro for premium features.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)
        }
    }

    private var productList: some View {
        VStack(spacing: 12) {
            ForEach(store.products) { product in
                ProductRow(product: product, owned: store.ownedProductIDs.contains(product.id))
                    .environment(store)
            }
        }
    }
}

struct ProductRow: View {
    @Environment(Store.self) private var store
    let product: Product
    let owned: Bool

    var body: some View {
        Button {
            guard !owned else { return }
            Task { await store.purchase(product) }
        } label: {
            HStack {
                VStack(alignment: .leading, spacing: 2) {
                    Text(product.displayName).font(.headline)
                    if let offer = product.subscription?.introductoryOffer {
                        Text("Free for \(offer.period.formatted())")
                            .font(.caption)
                            .foregroundStyle(.green)
                    } else if let sub = product.subscription {
                        Text("Renews every \(sub.subscriptionPeriod.formatted())")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
                Spacer()
                if owned {
                    Label("Owned", systemImage: "checkmark.seal.fill")
                        .foregroundStyle(.green)
                } else {
                    Text(product.displayPrice)
                        .font(.headline)
                        .padding(.horizontal, 12).padding(.vertical, 6)
                        .background(.tint, in: .capsule)
                        .foregroundStyle(.white)
                }
            }
            .padding()
            .background(.thinMaterial, in: .rect(cornerRadius: 12))
        }
        .buttonStyle(.plain)
    }
}

extension SubscriptionPeriod {
    func formatted() -> String {
        let n = value
        switch unit {
        case .day: return n == 1 ? "1 day" : "\(n) days"
        case .week: return n == 1 ? "week" : "\(n) weeks"
        case .month: return n == 1 ? "month" : "\(n) months"
        case .year: return n == 1 ? "year" : "\(n) years"
        @unknown default: return "\(n)"
        }
    }
}

App entry

StoreKitIAPLabApp.swift:

import SwiftUI

@main
struct StoreKitIAPLabApp: App {
    @State private var store = Store()

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

struct ContentView: View {
    @Environment(Store.self) var store
    @State private var showPaywall = false

    var body: some View {
        VStack(spacing: 16) {
            Text("Demo App")
                .font(.largeTitle)
            if store.ownedProductIDs.contains(ProductID.removeAds.rawValue) {
                Label("No ads ✨", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
            } else {
                Text("(banner ad placeholder)")
                    .padding()
                    .background(.yellow.opacity(0.3), in: .rect(cornerRadius: 8))
            }
            if isPro {
                Label("Pro subscriber", systemImage: "star.fill")
                    .foregroundStyle(.yellow)
            }
            Button("Upgrade…") { showPaywall = true }
                .buttonStyle(.borderedProminent)
        }
        .sheet(isPresented: $showPaywall) {
            PaywallView()
        }
    }

    private var isPro: Bool {
        store.ownedProductIDs.contains(ProductID.proMonthly.rawValue) ||
        store.ownedProductIDs.contains(ProductID.proYearly.rawValue)
    }
}

Test

Run on the simulator. Tap Upgrade → tap any product → the StoreKit testing flow appears (no real money, no Apple ID). After purchase, the row should switch to “Owned” and the ad placeholder/Pro badge updates.

Test the lifecycle:

  • Manage Transactions → Debug menu in Xcode (with the storekit file selected, you get Editor → “Manage StoreKit Transactions”). From here you can refund, expire, ask-to-buy-approve, and renew.
  • Refund a subscription → re-open the app → the badge should disappear (caught by Transaction.updates).
  • Manually expire the subscription → next entitlement refresh shows it gone.

Stretch

  1. Subscription status banner: when subscriptionState == .inGracePeriod or .inBillingRetryPeriod, show a yellow “Update your payment method” banner with a deep link to Settings.
  2. Promotional offers: add a winback offer in Products.storekit and surface it conditionally.
  3. Server-side validation stub: write a tiny Swift Vapor app that accepts the JWS transaction and verifies it against Apple’s public key (covered in Chapter 9.7). For the lab, mock it locally.
  4. Family Sharing badge: detect via transaction.ownershipType == .familyShared and show a “Shared by Family” tag.
  5. Sandbox testing: configure a real Sandbox tester in App Store Connect, sign into the simulator/device with that account, switch the scheme back to “Use Sandbox” → exercise the same flows end-to-end.

Notes

  • Subscriptions in the local StoreKit file renew aggressively fast (configurable in Manage Transactions). Don’t be alarmed if “1 month” passes in 5 seconds.
  • Transaction.currentEntitlements is the single source of truth. Do not persist “isPro” anywhere else.
  • For real shipping, you must declare every IAP in App Store Connect with screenshots and metadata; the local .storekit file is dev-only.
  • AppStore.sync() triggers an Apple ID sign-in prompt — only call from explicit “Restore Purchases” taps, never from app launch.

Next: Lab 7.4 — Sign in with Apple auth flow