Swift and SwiftUI tutorials for Swift Developers

How to add in-app purchases in SwiftUI

Developing an incredible application is only half the battle. The other half, often the most complex for independent developers and small startups, is turning that code into sustainable revenue.

For years, integrating In-App Purchases (IAP) on iOS was a daunting task. It involved dealing with SKPaymentQueue, complex delegates, receipt validation with OpenSSL, and proprietary backend servers. Fortunately, those days are long gone.

With the arrival of StoreKit 2 and its deep integration with Swift concurrency (async/await), implementing a robust monetization system in SwiftUI is now a fluid, secure, and almost enjoyable experience.

In this in-app purchases tutorial in SwiftUI, we will build a cross-platform purchase system (iOS, macOS, watchOS) from scratch, covering everything from the setup in App Store Connect to the “Paywall” user interface.


Part 1: Core Concepts and Strategy

Before writing a single line of code, we must understand what we are selling. Apple classifies purchases into four types:

  1. Consumables: Used once and depleted (e.g., coins in a game).
  2. Non-Consumables: Bought once and last forever (e.g., unlocking “Pro Mode”, photo filters). We will focus on this one for the tutorial.
  3. Auto-Renewable Subscriptions: Recurring billing (e.g., Netflix, Spotify).
  4. Non-Renewing Subscriptions: Access for a limited time (e.g., a season pass).

For this tutorial, we will simulate a productivity application where the user can buy a “Premium Version” (Lifetime)that unlocks unlimited features.


Part 2: Environment Setup (No Code Yet)

The number one mistake developers make is jumping into Xcode without configuring the metadata.

1. App Store Connect

To test real purchases (even in Sandbox mode), you need to define the products on Apple’s servers.

  1. Log in to App Store Connect.
  2. Go to “My Apps” and select your application.
  3. In the sidebar, look for Monetization > In-App Purchases.
  4. Click the + button and select Non-Consumable.
  5. Product ID: This is crucial. Use reverse domain notation. Example: com.yourcompany.yourapp.premium_lifetime.
  6. Fill in the Reference Name and Price (e.g., Tier 5 – $4.99).
  7. Important: Add a Localization (Name and Description visible to the user) and a screenshot (you can use a blank image temporarily for testing), otherwise the status will remain “Missing Metadata” and you won’t be able to test it.

2. Xcode Configuration

  1. Open your project in Xcode.
  2. Select your app target -> Signing & Capabilities tab.
  3. Click + Capability and search for In-App Purchase. This adds the framework to the project.

Part 3: The StoreKit Configuration File (The Productivity Secret)

Previously, testing purchases required a physical device and Sandbox accounts, which was slow and prone to errors. Xcode now allows for local testing.

  1. In Xcode, go to File > New > File...
  2. Search for StoreKit Configuration File.
  3. Name it Products.storekit and save it in your project.
  4. Open the file. You will see a visual interface.
  5. Click + at the bottom left and select Add Non-Consumable In-App Purchase.
  6. Enter the same Product ID you created in App Store Connect (com.yourcompany.yourapp.premium_lifetime).
  7. Set a price and description for testing.

Critical Step: To make Xcode use this file instead of connecting to Apple’s servers:

  1. Click on your app’s scheme (top, next to the simulator).
  2. Edit Scheme…
  3. In the Run tab, go to Options.
  4. Under StoreKit Configuration, select your Products.storekit file.

Now you can simulate purchases in the iOS simulator without an internet connection and with instant responses.


Part 4: The Business Logic (StoreManager)

We are going to create a StoreManager class that will be the brain of our operations. We will use the ObservableObjectpattern so that our SwiftUI views react to changes.

Create a file named StoreManager.swift:

import Foundation
import StoreKit

// Aliases for simplification
typealias Transaction = StoreKit.Transaction
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState

public enum StoreError: Error {
    case failedVerification
}

@MainActor
class StoreManager: ObservableObject {
    
    // Products available for purchase
    @Published private(set) var products: [Product] = []
    
    // State of whether the user has purchased the Pro version
    @Published private(set) var hasUnlockedPro: Bool = false
    
    // Your product IDs (must match App Store Connect and .storekit)
    private let productIds: [String] = ["com.yourcompany.yourapp.premium_lifetime"]
    
    // Task to listen for transaction updates in the background
    var updateListenerTask: Task<Void, Error>? = nil

    init() {
        // Start listening for transactions upon initialization
        updateListenerTask = listenForTransactions()
        
        // Start loading products and verify previous purchases
        Task {
            await requestProducts()
            await updateCustomerProductStatus()
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    // MARK: - 1. Fetch Products
    func requestProducts() async {
        do {
            // Asynchronous call to Apple to get details (price, currency, etc.)
            let products = try await Product.products(for: productIds)
            self.products = products.sorted(by: { $0.price < $1.price })
        } catch {
            print("Error fetching products: \(error)")
        }
    }
    
    // MARK: - 2. Purchase Product
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        
        switch result {
        case .success(let verification):
            // The purchase was successful, now we verify the cryptographic signature
            let transaction = try checkVerified(verification)
            
            // Deliver content to the user
            await updateCustomerProductStatus()
            
            // Tell StoreKit we have finished processing
            await transaction.finish()
            
        case .userCancelled, .pending:
            break
        default:
            break
        }
    }
    
    // MARK: - 3. Check User Status (Restore)
    func updateCustomerProductStatus() async {
        // Iterate over the user's current entitlements
        for await result in Transaction.currentEntitlements {
            do {
                let transaction = try checkVerified(result)
                
                // If we find our product ID, unlock the Pro version
                if transaction.productID == "com.yourcompany.yourapp.premium_lifetime" {
                    hasUnlockedPro = true
                }
            } catch {
                print("Verification failed")
            }
        }
    }
    
    // MARK: - 4. Active Transaction Listener
    /* This is vital. If a purchase is approved outside the app (e.g., Parental Control, 
     or a subscription renews), this listener will catch it.
    */
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    
                    // Update the UI on the main thread
                    await self.updateCustomerProductStatus()
                    
                    await transaction.finish()
                } catch {
                    print("Error in verified transaction")
                }
            }
        }
    }
    
    // Helper function to verify Apple's cryptographic signature
    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
    }
}

Code Analysis

  • @MainActor: Ensures that all @Published updates occur on the main thread, avoiding UI issues.
  • Transaction.currentEntitlements: This is the magic of StoreKit 2. You no longer need an explicit “Restore Purchases” button that calls a complex function. Simply by iterating over this asynchronous sequence, you know what the user owns.
  • Transaction.updates: An infinite loop that listens for App Store events. It is mandatory to implement this to handle edge cases like interrupted purchases or deferred parental approvals.

Part 5: Creating the User Interface (Paywall)

Now that we have the logic, we need an attractive view to sell. SwiftUI makes this trivial.

Create PaywallView.swift:

import SwiftUI
import StoreKit

struct PaywallView: View {
    @EnvironmentObject var storeManager: StoreManager
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // Header
                Image(systemName: "crown.fill")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 80, height: 80)
                    .foregroundColor(.yellow)
                    .padding(.top, 40)
                
                Text("Unlock Premium")
                    .font(.largeTitle.bold())
                
                Text("Get unlimited access to all features, cloud sync, and priority support.")
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
                    .foregroundColor(.secondary)
                
                // Feature List
                VStack(alignment: .leading, spacing: 15) {
                    FeatureRow(icon: "icloud", text: "iCloud Sync")
                    FeatureRow(icon: "chart.bar", text: "Advanced Statistics")
                    FeatureRow(icon: "lock.open", text: "Ad-Free")
                }
                .padding(.vertical)
                
                // Product Cards
                if storeManager.products.isEmpty {
                    ProgressView("Loading prices...")
                } else {
                    ForEach(storeManager.products) { product in
                        ProductButton(product: product) {
                            Task {
                                try? await storeManager.purchase(product)
                            }
                        }
                    }
                }
                
                // Restore Button (Although StoreKit 2 is automatic, Apple recommends adding it)
                Button("Restore Purchases") {
                    Task {
                        await storeManager.updateCustomerProductStatus()
                    }
                }
                .font(.footnote)
                .foregroundColor(.secondary)
                .padding(.top)
            }
            .padding()
        }
        .onChange(of: storeManager.hasUnlockedPro) { newValue in
            if newValue {
                // Close the paywall if the purchase is successful
                dismiss()
            }
        }
    }
}

// Subview for the purchase button
struct ProductButton: View {
    let product: Product
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            HStack {
                VStack(alignment: .leading) {
                    Text(product.displayName)
                        .font(.headline)
                    Text(product.description)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                
                Spacer()
                
                Text(product.displayPrice)
                    .padding(8)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
            .padding()
            .background(Color("CardBackground")) // Define this color in your Assets
            .cornerRadius(12)
            .overlay(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(Color.blue, lineWidth: 2)
            )
        }
        .buttonStyle(.plain)
    }
}

struct FeatureRow: View {
    let icon: String
    let text: String
    
    var body: some View {
        HStack {
            Image(systemName: icon)
                .foregroundColor(.blue)
                .frame(width: 30)
            Text(text)
        }
    }
}

Part 6: Integration into the App Flow

Finally, we need to connect everything at our application’s entry point (App.swift).

import SwiftUI

@main
struct MyAwesomeApp: App {
    // Initialize StoreManager as a StateObject so it lives for the entire session
    @StateObject private var storeManager = StoreManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(storeManager) // Inject into the environment
        }
    }
}

And in your ContentView, you can control access to premium features:

struct ContentView: View {
    @EnvironmentObject var storeManager: StoreManager
    @State private var showPaywall = false
    
    var body: some View {
        NavigationStack {
            VStack {
                if storeManager.hasUnlockedPro {
                    Text("Welcome Premium User!")
                        .font(.title)
                        .foregroundColor(.green)
                    // Exclusive content here
                } else {
                    Text("Free Mode")
                    Button("Go Premium") {
                        showPaywall = true
                    }
                    .buttonStyle(.borderedProminent)
                }
            }
            .sheet(isPresented: $showPaywall) {
                PaywallView()
            }
        }
    }
}

Part 7: Testing and Debugging (Transaction Manager)

One of the best features of developing with Xcode and .storekit files is the Transaction Manager.

  1. Run the app in the simulator.
  2. Open the PaywallView and buy the product.
  3. You will see the UI update instantly.
  4. Now, in Xcode, go to the menu Debug > StoreKit > Manage Transactions.

Here you will see a floating window with all purchases made in the simulator. You can:

  • Delete transactions (to simulate a new user).
  • Refund transactions (to test how your app reacts if someone returns the product).
  • Simulate billing errors.
  • Approve/Decline “Ask to Buy” purchases (parental control).

This tool is invaluable for ensuring that your listenForTransactions() handles revocations (refunds) correctly, blocking access to premium content if the purchase disappears.


Part 8: Cross-Platform Adaptation (macOS and watchOS)

The beauty of SwiftUI and StoreKit 2 is that the logic code (StoreManager.swift) is 100% reusable on macOS and watchOS. You don’t need to change a single comma in the logic.

However, the UI (PaywallView) might need adjustments:

  • macOS: Instead of .sheet, you might prefer a modal window or a sidebar view. Buttons and spacing should adapt to the mouse pointer.
  • watchOS: The screen is small. Simplify the description. Show only the large “Buy” button and perhaps a single benefit sentence.

Example of conditional adaptation in PaywallView:

#if os(watchOS)
ScrollView {
    VStack {
        Text("Go Pro").font(.headline)
        // Simplified button
    }
}
#else
// Your complex design for iOS/macOS
#endif

Final Considerations and Best Practices

1. Error Handling

In the example code, we use try? or simple catch blocks. In a production app, you should show alerts to the user if the purchase fails due to network issues or if the Apple ID is not configured.

2. StoreKit Views (iOS 17+ Feature)

Apple introduced StoreView and SubscriptionStoreView in iOS 17. These are prefabricated views that render the Paywall automatically based on your App Store Connect metadata. If you are looking for the fastest possible integration and only support iOS 17+, you can replace our entire PaywallView with:

import StoreKit

struct SimplePaywall: View {
    var body: some View {
        SubscriptionStoreView(groupID: "your_subscription_group")
    }
}

Note: This works best for subscriptions but has options for non-consumables.

3. Server-Side Validation

For high-security applications (e.g., games with currency or banking apps), local validation (checkVerified in our code) is very secure but not invulnerable to devices with advanced Jailbreaks. If fraud risk is high, you should send the transaction.jwsRepresentation to your own server and validate it directly with Apple’s API.


Conclusion

Integrating purchases in SwiftUI has gone from a “spaghetti” code nightmare to a clean, modern architecture thanks to StoreKit 2. By following this tutorial, you have created a solid foundation:

  1. A reactive and thread-safe StoreManager.
  2. A fast local testing environment with .storekit files.
  3. declarative UI that responds to purchase status.

You now have the tools not only to create software but to build a sustainable business in the Apple ecosystem. The next step is to experiment with Auto-Renewable Subscriptions and offer free trials to maximize your conversion.

f you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Best SwiftUI Packages

Next Article

iOS Swift Conferences 2026

Related Posts