How to Implement In-App Purchase in SwiftUI
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
-
@Observable StoreManagerinitializer — On init, the manager immediately spawns a detached backgroundTaskcallinglistenForTransactions()and an async task to runrefresh(). This ensures products are loaded and the transaction listener is active before the user sees any UI. -
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. -
product.purchase()+checkVerified— Thepurchase()method returns aProduct.PurchaseResult. The.successcase contains aVerificationResult<Transaction>; callingcheckVerifiedconfirms Apple's cryptographic signature before you unlock content. Always calltransaction.finish()afterwards—this removes the transaction from the queue so it isn't re-processed. -
Transaction.currentEntitlements— AnAsyncSequencethat yields every active, non-revoked transaction for the current user. Iterating it inupdateEntitlements()rebuildspurchasedIDson launch and after any purchase or restore. Because SK2 stores the signed JWS locally, this requires no network call. -
Transaction.updateslistener — ThisAsyncSequencefires 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
-
Forgetting
transaction.finish(). If you never callfinish(), the transaction re-appears inTransaction.updateson every launch. StoreKit 2 does not auto-finish transactions, unlike the old SK1 observer pattern. -
Testing without a
.storekitconfig file. Calls toProduct.products(for:)return an empty array in the Simulator unless you attach a StoreKit configuration file (File → New → StoreKit Configuration File) and select it under Scheme → Run → Options → StoreKit Configuration. This is a frequent "why are no products loading?" moment. -
Checking entitlements only at purchase time. Users can receive entitlements outside your app (family sharing, subscription renewal, refund reversal). Always call
updateEntitlements()in.task {}on your root view or onscenePhasechange to.active, not just after a successfulpurchase()call. -
Missing
accessibilityLabelon price buttons. VoiceOver reads the raw button title "Buy" without context. Add.accessibilityLabel("Buy \(product.displayName) for \(product.displayPrice)")so users know exactly what they're purchasing before double-tapping.
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.