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
- New Xcode project → App → SwiftUI → name
StoreKitIAPLab. - File → New → File → App → StoreKit Configuration File → name
Products.storekit→ check “Sync with App Store Connect: No” (local-only). - 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.
- ID
- Non-Consumable — ID
- 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
- Subscription status banner: when
subscriptionState == .inGracePeriodor.inBillingRetryPeriod, show a yellow “Update your payment method” banner with a deep link to Settings. - Promotional offers: add a winback offer in
Products.storekitand surface it conditionally. - 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.
- Family Sharing badge: detect via
transaction.ownershipType == .familySharedand show a “Shared by Family” tag. - 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.currentEntitlementsis 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
.storekitfile is dev-only. AppStore.sync()triggers an Apple ID sign-in prompt — only call from explicit “Restore Purchases” taps, never from app launch.