Get Paid While You Sleep

This tutorial will walk you through key StoreKit 2 concepts, including:

  • Setting up a StoreKit configuration file for local sandbox testing.
  • Integrating StoreKit 2 to handle, complete, and verify purchases.
  • Using StoreKit views to showcase products or fetching them from the App Store with products(for:).
  • Monitoring transaction state changes with the transaction listener to ensure real-time updates.
  • Configuring products in App Store Connect.

The guide features Swift code examples and images from my latest app, Coffee Break News, where you can find all the workflows showcased in this tutorial.

🧐 Sneak Peek: Adding a Tip Jar with StoreKit 2 in SwiftUI

Here is a glimpse of what you will achieve by the end of this guide β€” integrating a Tip Jar that allows users to support your work. After a successful purchase, a thank-you message will be shown to express appreciation πŸŽ‰!

Table of Contents

Understanding In-App Purchases (IAPs)

In-App purchases (IAPs) are digital products available for purchase within an app. Using the In-App Purchase API, we can offer users the ability to buy digital content and services directly in your app, with promoted products even appearing on the App Store.

Apple's IAPs come in four main categories:

  • Consumable: One-time use items like in-game currency or extra lives that can be repurchased.
  • Non-Consumable: Permanent purchases, such as unlocking a feature or a new level, that don't expire.
  • Auto-Renewable Subscriptions: Recurring subscriptions that renew automatically until canceled, providing ongoing access to content or services.
  • Non-Renewable Subscriptions: Time-limited subscriptions that require users to repurchase once they expire.

Adding the In-App Purchase Capability

The first step is to set up our project by enabling in-app purchases under the "Signing & Capabilities" tab in Xcode.

Creating a StoreKit Configuration File

Here is a quick rundown of how to create, set up, and enable a StoreKit configuration file. This file essentially simulates the App Store, allowing Xcode to fetch products from it during development.

  • Go to File > New > File From Template.
  • In the search bar, type "StoreKit".
  • Select "StoreKit Configuration File" from the results.
  • Give it a name, ensure "Sync this file with an app in App Store Connect" remains unchecked, and click Next. (We will handle syncing later.)

Defining In-App Purchase Products

Now, let's add the IAP products. Since we are building a sample app where users can tip the developer by purchasing different types of coffee, we will focus on "Consumable" products. Follow these steps to create them:

  • In the StoreKit configuration file editor in Xcode, click the "+" button in the bottom left corner.
  • Choose "Consumable" as the In-App Purchase type.
  • Fill in the necessary details: reference name, product ID, price, and at least one localization.

Activating the StoreKit Configuration File

Just creating a StoreKit configuration file isn't sufficient β€” it must be explicitly enabled in your Xcode scheme. Follow these steps to set it up:

  • Click on the scheme and choose "Edit Scheme".
  • Navigate to Run > Options.
  • Select a value for "StoreKit configuration".

Building a Tip Jar with StoreKit 2 in Swift

Before interacting with StoreKit APIs in Swift, we first need to ensure that our products are properly set up β€” whether they are created via App Store Connect or a StoreKit configuration file.

Retrieving Products

The first step is to display a paywall featuring all available in-app purchase options, allowing users to tip the developer with a simple tap. In this example, the app will present three tipping options β€” small, medium and large coffees β€” each with a different price.

By the end of this step, here is what we will have implemented:

Retrieving products from StoreKit 2 only takes a few lines of code:

import StoreKit
 
enum TipProductIdentifier: String, CaseIterable {
    case smallTip = "com.tiago.henriques.iOSCoffeeBreak.smallTip"
    case mediumTip = "com.tiago.henriques.iOSCoffeeBreak.mediumTip"
    case largeTip = "com.tiago.henriques.iOSCoffeeBreak.largeTip"
}
 
@Observable
class StoreManager {
    private(set) var products = [Product]()
 
    func loadProducts() async throws {
        let products = try await Product
            .products(for: TipProductIdentifier.allCases
                .map { $0.rawValue })
            .sorted(by: { $0.price > $1.price })
        self.products = products
    }
}

The code snippet above begins by importing StoreKit and defining an array of product identifier strings which are used to display items on the paywall. These identifiers must align with the products configured in either a StoreKit Configuration File or App Store Connect.

Next, we will set up a StoreManager at the root of the app and pass it down as an environment object. This ensures that all SwiftUI views can easily access the same instance of the StoreManager enabling seamless integration with StoreKit.

import SwiftUI
 
@main
struct iOSCoffeeBreakApp: App {
    @State private var store = StoreManager()
 
    var body: some Scene {
        WindowGroup {
            AppView()
                .environment(store)
        }
    }
}

Displaying Products

At WWDC23, Apple introduced a powerful set of StoreKit APIs designed to simplify the process of creating merchandising UI. With Xcode 15 and later, StoreKit now offers built-in SwiftUI views to help developers build declarative in-app purchase interfaces with ease.

Apple's Merchandising Views:

These views streamline the merchandising process by abstracting the App Store’s data flow and presenting system-optimized UI elements for in-app purchases.

πŸ“Œ For a deep dive, check out Apple's Meet StoreKit for SwiftUI session from WWDC23.

Customizing the UI

Apple provides three layout styles to tailor the presentation:

  • Compact: ideal for displaying multiple products in a limited space.
  • Regular: the default appearance.
  • Large: best for highlighting premium offers.

The large ProductView style helps us display our best value prominently by just adding one view modifier. Now it is time to show those products in the view:

import StoreKit
 
struct CoffeeShopView: View {
    @Environment(StoreManager.self) var store: StoreManager
 
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack {
                    if let product = store.bestProduct {
                        ProductView(id: product.id) {
                            CoffeeProductIcon(product: product)
                        }
                        .padding()
                        .productViewStyle(.large)
                        .background(
                            Color.secondarySystemGroupedBackground, 
                            in: .rect(cornerRadius: 20)
                        )
                    }
 
                    if !store.standardProducts.isEmpty {
                        Text("More Products")
                            .font(.title3.weight(.medium))
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.top, 10)
 
                        ForEach(store.standardProducts) { product in
                            ProductView(id: product.id) {
                                CoffeeProductIcon(product: product)
                            }
                            .padding(.vertical)
                            .productViewStyle(.compact)
                            .padding()
                            .background(
                                Color.secondarySystemGroupedBackground, 
                                in: .rect(cornerRadius: 20)
                            )
                        }
                    }
                }
                .scrollClipDisabled()
            }
            .navigationTitle("Coffee Shop")
            .task {
                try? await store.loadProducts()
            }
 
            [...]
        }
    }
}
 
#Preview {
    CoffeeShopView()
        .environment(StoreManager())
}

The Product View helps us get up and running with ease by turning our product identifiers and icons into a functional and well designed store. This view automatically adjusts to different platforms, so we already have a shop that looks great on iPad, Mac and Apple Watch!

In this snippet, the StoreKit 2 method for fetching products is moved into a new loadProducts() method. This function is then called when our view appears by using .task().

Managing Purchases

Processing purchases from StoreKit views is straightforward - simply use the onInAppPurchaseCompletion modifier and specify a function to handle the purchase outcome.

This modifier can be applied to any view, and it will automatically trigger whenever a purchase is completed within any nested StoreKit view. When a transaction occurs, the modifier provides details about the purchased product along with the transaction result β€” whether it was successful or not. Now, let's integrate this into our CoffeeShopView to ensure successful purchases are properly processed.

import StoreKit
 
struct CoffeeShopView: View {
    @Environment(StoreManager.self) var store: StoreManager
 
    var body: some View {
        NavigationStack {
            ScrollView {
                [...]
            }
            .navigationTitle("Coffee Shop")
            .task {
                try? await store.loadProducts()
            }
            .onInAppPurchaseCompletion { item, result in
                switch result {
                case .success(let purchaseResult):
                    switch purchaseResult {
                    case .success(let verificationResult):
                        switch verificationResult {
                        case .verified(let transaction):
                            // Successful purchase
                            await transaction.finish()
 
                        case .unverified(let transaction, _):
                            // Successful purchase 
                            // but transaction/receipt can't be verified
                            // Could be a jailbroken phone
                            await transaction.finish()
                        }
                    case .pending:
                        // Transaction waiting 
                        // on SCA (Strong Customer Authentication) or
                        // approval from Ask to Buy
                        break
                    case .userCancelled:
                        break
                    @unknown default:
                        break
                    }
 
                case .failure(let error):
                    break
                }
            }
        }
    }
}

Keep in mind that using these modifiers is entirely optional. By default, successful transactions from StoreKit views will emit from the Transactions.updates sequence, bu you have the option to add onInAppPurchaseCompletion to handle the result directly.

Monitoring Transaction Updates

At the moment, we are not tracking transaction updates, which means purchases could be interrupted without proper handling. If we don't listen for these updates, we risk missing important verification steps and failing to grant users access to their purchased content. To address this, we will use an async sequence to continuously monitor transaction updates. This requires setting up a listener when our store is initialized.

First, we need to define a property for our transaction listener within the store. Next, we will create a function called configureTransactionListener which returns Task<Void, Never>. This function will observe all incoming transaction updates, verify them, and process them accordingly.

import StoreKit
 
@Observable
class StoreManager {
    private var transactionListener: Task<Void, Never>?
 
    [...]
 
    func configureTransactionListener() -> Task<Void, Never> {
        Task(priority: .background) { [unowned self] in
            do {
                for await result in Transaction.updates {
                    let transaction = try self.checkVerified(result)
                    await transaction.finish()
                }
            } catch {
                self.setError(.system(error))
            }
        }
    }
}

We will then configure this listener within our init method to ensure it starts running as soon as the store is initialized. Additionally, to handle potential edge cases where the object is disposed of, we should call cancel() on the listener when needed.

import StoreKit
 
@Observable
class StoreManager {
    private var transactionListener: Task<Void, Never>?
 
    [...]
 
    init() {
        transactionListener = configureTransactionListener()
    }
 
    deinit {
        transactionListener?.cancel()
    }
 
    [...]
}

With this setup, we eliminate the warning about missing transaction updates and ensure a smooth purchasing experience.

Configuring Products in App Store Connect

The final step is to set up our products in App Store Connect to make them available for purchase.

App Store Connect requirements

Before adding products, ensure that your "Agreements, Tax, and Banking Information" are fully set up. To verify this:

  • Navigate to App Store Connect > Banking
  • Confirm that all statuses are marked as "Active"

Adding Consumables to App Store Connect

To create In-App Purchases, follow these steps:

  • Select your app in App Store Connect
  • Go to the "In-App Purchases" section
  • Click Create a new in-app purchase
  • Choose the product type (Consumable for this example)
  • Set a Reference Name and Product ID
  • Complete all required metadata, including price, localization, and review details, using the values from your local StoreKit configuration file

Important: When submitting your app for review, remember to include your In-App Purchases under the "In-App Purchases and Subscription" section.

🀝 Wrapping Up

With its streamlined API, StoreKit 2 makes integrating in-app purchases easier than ever. And if you are building with SwiftUI, Apple's new StoreKit views handle much of the heavy lifting, allowing you to focus on refining other parts of your app. I hope this StoreKit 2 guide has provided you with the insights you need to start monetizing your apps.

Happy coding! πŸš€

All of this paywall implementation process could be made significantly easier and more flexible by using RevenueCat Paywalls! They even allow us to update and customize our entire paywall interface remotely, eliminating the need to wait for App Store review!

Reference

More

In case you have any questions reach me at tiago.fig.henriques.2@gmail.com or see more of my work on Twitter.

Tiago Henriques writing: Get Paid While You Sleep