Skip to main content
Tutorials

iOS In-App Purchases With StoreKit 2: Test in the Sandbox

Last updated

To add in-app purchases to an iOS app with StoreKit 2, do four things: sign the Paid Apps Agreement and complete banking and tax information, create products in App Store Connect, load them with Product.products(for:), buy with purchase() and listen with Transaction.updates, then install a Debug build and buy once in the sandbox. StoreKit 2 needs iOS 15 or later, and sandbox purchases are free. The Apple rules below are current as of October 2026 and link to official pages.

Have these ready before you start.

  • An account enrolled in the Apple Developer Program, with an app record for this app already created in App Store Connect. If you don’t have one yet, see Publish to the App Store on Windows.
  • A SwiftUI project that KXBuild can install and run on your iPhone, with a deployment target of iOS 15 or later. If you don’t have one, create it with your first SwiftUI app.
  • An iPhone with Developer Mode turned on. See how to turn on Developer Mode for where the switch is.

What are the steps to add in-app purchases?

Step Where Who
1. Sign the Paid Apps Agreement, enter banking and tax information Business in App Store Connect Account Holder (banking information can also be entered by an Admin or Finance role)
2. Confirm the App ID is explicit, generate a provisioning profile developer.apple.com Developer
3. Create products The app’s page in App Store Connect Developer, App Manager and similar roles
4. Write the StoreKit 2 code VS Code Developer
5. Create a sandbox tester Users and Access in App Store Connect Developer role or higher
6. Test a purchase on the device iPhone Developer

Why must the Paid Apps Agreement be active first?

Apple’s sandbox testing guide lists this as a requirement for testing. The Account Holder must sign the Paid Apps Agreement. The agreement for free apps is in effect by default, but in-app purchases need this one signed separately.

  1. Sign in to App Store Connect as the Account Holder and click Business at the top.
  2. On the Agreements tab, find the Paid Apps row and click View and Agree to Terms. Apple describes the steps in Sign and update agreements.
  3. Add a bank account on the same page. You can only add one after you sign the agreement, and the details must match your bank’s records exactly. See Enter banking information.
  4. Fill in the tax forms. Apple only processes your banking information after you submit the required tax forms.

Check the status as described in View agreements status. Pending User Info means you signed but haven’t finished entering your information, so the agreement isn’t in effect yet. Processing means Apple is reviewing it. The agreement is in effect only when it shows Active. While it isn’t, loading products in code often returns an empty array, and this is the most common reason products don’t show up.

What App ID and profile do in-app purchases need?

Apple’s Register an App ID states that explicit App IDs have In-App Purchase enabled by default. It appears grayed out and checked in the Capabilities list, so you don’t need to turn it on. In-app purchases also don’t need any key in the entitlements file.

The one thing to watch is that the App ID must be an explicit Bundle ID, such as com.example.myapp. Wildcard App IDs (com.example.*) don’t support in-app purchases. If your Development profile is a wildcard profile, register an explicit App ID for this Bundle ID on the Identifiers page, generate a Development profile for it on the Profiles page, put it in your project’s keychain folder and delete the old wildcard file. For the full steps see Get an iOS certificate, p12 and provisioning profile on Windows and Certificates and signing.

Double-click a .mobileprovision in VS Code to see the App ID it’s tied to. Make sure it shows the full Bundle ID with no asterisk.

How do I create products in App Store Connect?

Choose a product type

Type What it does Example Can be restored
Consumable Used up once, can be bought again Game coins, a one-time boost No, you track the balance yourself
Non-Consumable Bought once, lasts forever Unlock Pro, remove ads Yes
Auto-Renewable Subscription Charges every period and renews until the user cancels Monthly or yearly membership Yes
Non-Renewing Subscription Fixed duration, doesn’t renew One year of access to a library Yes, but you calculate the expiry yourself

The definitions follow Apple’s In-App Purchase types. Most apps only need the first three, and the code in this tutorial covers those three.

Create a consumable or non-consumable

  1. In Apps in App Store Connect, select your app, then click In-App Purchases under Monetization in the sidebar.
  2. Click “+” and choose Consumable or Non-Consumable.
  3. Enter a Reference Name (only you see it) and a Product ID, then click Create.
  4. On the details page, set the price, a display name and description in at least one language, and the review information (a screenshot and notes).

Use your Bundle ID plus a suffix for the Product ID, for example com.example.myapp.coins100 or com.example.myapp.pro. Your code uses it to load the product, so treat it as permanent once created.

For sandbox testing you need at least the Reference Name, Product ID, a localized name and a price. After you change product information, it can take up to an hour to appear in the sandbox. See Create consumable or non-consumable In-App Purchases.

Create an auto-renewable subscription

Subscriptions live in a subscription group. A user can have only one subscription from a group at a time, which suits tiers like monthly and yearly plans that users upgrade or downgrade between.

  1. Click Subscriptions under Monetization in the sidebar, click “+” to create a subscription group, and enter a Reference Name.
  2. Open the group, click Create, and enter the subscription’s Reference Name and Product ID, for example com.example.myapp.vip.monthly.
  3. On the subscription’s page, choose a duration (1 week, 1 month, 2 months, 3 months, 6 months or 1 year), set the price and availability, add a localized display name and description, fill in the review information and save.
  4. Add a display name in at least one language to the subscription group itself too.

Apple’s Offer auto-renewable subscriptions has the authoritative steps.

Which StoreKit 2 APIs do I need?

StoreKit 2 is available from iOS 15. Everything uses async/await, and transactions come with Apple’s signature verification, so you no longer parse receipts yourself.

API What it does
Product.products(for:) Loads products from the App Store by Product ID, with name, description and localized price
product.purchase() Shows the system purchase sheet and returns success, user cancelled or pending
VerificationResult Wraps a transaction. .verified passed signature verification, .unverified didn’t
transaction.finish() Tells the App Store you delivered the content. If you don’t call it, the transaction comes back on the next launch
Transaction.updates New transactions that arrive while the app is running, such as a parent’s approval, a purchase on another device or a renewal
Transaction.currentEntitlements What the user currently owns, used to decide what’s unlocked
AppStore.sync() Restores purchases. It asks the user to authenticate with their Apple Account, so call it only when the user taps a button

Start listening to Transaction.updates as soon as the app launches. Apple’s updates documentation says unfinished transactions from earlier are delivered through it right after launch, and you miss them if you aren’t listening.

currentEntitlements contains only non-consumables, auto-renewable subscriptions that are active or in a grace period, and non-renewing subscriptions. It doesn’t include consumables, and refunded or revoked purchases aren’t in it either. See the currentEntitlements documentation. That’s why you deliver and record consumables the moment the purchase succeeds.

Write a complete StoreManager

The code below works with a deployment target of iOS 15 or later. It uses ObservableObject with @MainActor and also compiles in Swift 6 strict concurrency mode. If your deployment target is iOS 17 or later you can use @Observable instead, as shown at the end of this section.

Create StoreManager.swift in your app’s source folder and replace the three Product IDs with the ones you entered in App Store Connect.

  1import Foundation
  2import StoreKit
  3
  4/// Unwraps a VerificationResult and throws for transactions that failed Apple's signature check
  5func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
  6    switch result {
  7    case .verified(let value):
  8        return value
  9    case .unverified(_, let error):
 10        throw error
 11    }
 12}
 13
 14@MainActor
 15final class StoreManager: ObservableObject {
 16    // Must match the Product IDs in App Store Connect exactly
 17    static let coinsID = "com.example.myapp.coins100"     // Consumable
 18    static let proID = "com.example.myapp.pro"            // Non-consumable
 19    static let vipID = "com.example.myapp.vip.monthly"    // Auto-renewable subscription
 20    static let allIDs: Set<String> = [coinsID, proID, vipID]
 21
 22    @Published private(set) var products: [Product] = []
 23    @Published private(set) var purchasedIDs: Set<String> = []
 24    @Published private(set) var coins: Int = UserDefaults.standard.integer(forKey: "coins")
 25    @Published var message: String?
 26
 27    private var updatesTask: Task<Void, Never>?
 28
 29    var isPro: Bool { purchasedIDs.contains(Self.proID) }
 30    var isVIP: Bool { purchasedIDs.contains(Self.vipID) }
 31
 32    init() {
 33        // Start listening right away to catch unfinished transactions and ones made outside the app
 34        updatesTask = Task { [weak self] in
 35            for await result in Transaction.updates {
 36                await self?.handle(result)
 37            }
 38        }
 39    }
 40
 41    /// Loads products. A Product ID that doesn't exist isn't an error, it's just missing from the result
 42    func loadProducts() async {
 43        do {
 44            let list = try await Product.products(for: Self.allIDs)
 45            products = list.sorted { $0.price < $1.price }
 46            let missing = Self.allIDs.subtracting(list.map(\.id))
 47            if !missing.isEmpty {
 48                print("Products not returned: \(missing.sorted())")
 49            }
 50        } catch {
 51            message = "Couldn't load products: \(error.localizedDescription)"
 52        }
 53    }
 54
 55    /// Starts a purchase
 56    func purchase(_ product: Product) async {
 57        do {
 58            let result = try await product.purchase()
 59            switch result {
 60            case .success(let verification):
 61                let transaction = try checkVerified(verification)
 62                await deliver(transaction)
 63                await transaction.finish()
 64                message = "Purchased: \(product.displayName)"
 65            case .userCancelled:
 66                break
 67            case .pending:
 68                message = "Waiting for approval (for example from a parent). It arrives automatically once approved."
 69            @unknown default:
 70                break
 71            }
 72        } catch {
 73            message = "Purchase failed: \(error.localizedDescription)"
 74        }
 75    }
 76
 77    /// Restores purchases. Call only when the user taps a button
 78    func restore() async {
 79        do {
 80            try await AppStore.sync()
 81            await refreshEntitlements()
 82            message = "Purchases synced"
 83        } catch {
 84            message = "Restore failed: \(error.localizedDescription)"
 85        }
 86    }
 87
 88    /// Recalculates the non-consumables and subscriptions the user owns
 89    func refreshEntitlements() async {
 90        var ids: Set<String> = []
 91        for await result in Transaction.currentEntitlements {
 92            guard let transaction = try? checkVerified(result) else { continue }
 93            if transaction.revocationDate != nil { continue }
 94            if let expiration = transaction.expirationDate, expiration < Date() { continue }
 95            ids.insert(transaction.productID)
 96        }
 97        purchasedIDs = ids
 98    }
 99
100    private func handle(_ result: VerificationResult<Transaction>) async {
101        guard let transaction = try? checkVerified(result) else {
102            print("Ignored a transaction that failed verification")
103            return
104        }
105        await deliver(transaction)
106        await transaction.finish()
107    }
108
109    /// Delivers content: adds coins for consumables, refreshes entitlements for everything else
110    private func deliver(_ transaction: Transaction) async {
111        if transaction.productType == .consumable {
112            guard transaction.revocationDate == nil else { return }
113            coins += 100
114            UserDefaults.standard.set(coins, forKey: "coins")
115        } else {
116            await refreshEntitlements()
117        }
118    }
119}

A few things are easy to get wrong.

  • Deliver first, then finish. If you reverse the order and the app is killed between the two steps, the user paid but got nothing, and the transaction isn’t delivered again.
  • Store the consumable balance yourself. The example keeps it in UserDefaults only for the demo. A production app should send the transaction to your own server and record it there, or the coins are gone when the user deletes and reinstalls the app.
  • .pending isn’t a failure. Family members with Ask to Buy turned on and payments that need extra verification end up here. Once approved, the transaction arrives through Transaction.updates, where handle already picks it up.
  • Non-renewing subscriptions have no expirationDate. They stay in currentEntitlements, so calculate the expiry from the purchase date yourself.

Use @Observable with iOS 17 or later

Change the class declaration as below, remove every @Published, add import Observation after import Foundation, and leave the rest of the code as it is.

1@MainActor
2@Observable
3final class StoreManager {
4    // Remove @Published from the properties, keep everything else
5}

Where you use it, replace @StateObject with @State, .environmentObject(store) with .environment(store), and @EnvironmentObject with @Environment(StoreManager.self).

SwiftUI store screen

Create StoreView.swift. It lists the products with price buttons, shows what the user owns and has a restore button.

 1import SwiftUI
 2import StoreKit
 3
 4struct StoreView: View {
 5    @EnvironmentObject private var store: StoreManager
 6
 7    var body: some View {
 8        List {
 9            Section("Products") {
10                if store.products.isEmpty {
11                    Text("Loading products…").foregroundStyle(.secondary)
12                }
13                ForEach(store.products) { product in
14                    HStack {
15                        VStack(alignment: .leading, spacing: 4) {
16                            Text(product.displayName).font(.headline)
17                            Text(product.description)
18                                .font(.caption)
19                                .foregroundStyle(.secondary)
20                        }
21                        Spacer()
22                        if store.purchasedIDs.contains(product.id) {
23                            Text("Owned").foregroundStyle(Color.green)
24                        } else {
25                            Button(product.displayPrice) {
26                                Task { await store.purchase(product) }
27                            }
28                            .buttonStyle(.borderedProminent)
29                        }
30                    }
31                }
32            }
33
34            Section("My purchases") {
35                Text("Coins: \(store.coins)")
36                Text(store.isPro ? "Pro: unlocked" : "Pro: locked")
37                Text(store.isVIP ? "Membership: active" : "Membership: not subscribed")
38            }
39
40            Section {
41                Button("Restore Purchases") {
42                    Task { await store.restore() }
43                }
44            } footer: {
45                if let message = store.message {
46                    Text(message)
47                }
48            }
49        }
50        .task {
51            await store.loadProducts()
52            await store.refreshEntitlements()
53        }
54    }
55}

Then change your app’s entry file (for example MyAppApp.swift) as below, so StoreManager is created when the app launches and only one exists for the whole run.

 1import SwiftUI
 2
 3@main
 4struct MyAppApp: App {
 5    @StateObject private var store = StoreManager()
 6
 7    var body: some Scene {
 8        WindowGroup {
 9            StoreView()
10                .environmentObject(store)
11        }
12    }
13}

Use product.displayPrice for the price button. It’s already formatted in the currency of the user’s region, so don’t build the currency symbol and number yourself. App Review Guideline 3.1.1 requires a restore mechanism for restorable purchases, so keep the Restore Purchases button.

In SwiftUI projects with a deployment target of iOS 17 or later, Apple recommends starting purchases with the @Environment(\.purchase) environment value. It returns the same result as product.purchase(), so your handling code stays the same. Testing with a local .storekit configuration file only works inside Xcode, so this tutorial uses the sandbox throughout.

How do I create a sandbox tester?

A sandbox tester is a test Apple Account for in-app purchases. Purchases with it aren’t charged.

  1. In App Store Connect, click Users and Access at the top, then Sandbox in the top navigation, then “+”. The first time, click Create Test Account.
  2. Enter a name, email and password, choose an App Store country or region and click Create.

The email can’t already be registered as an Apple Account, and it can’t have been used to buy App Store content. If your email provider supports plus addressing, you can create several testers from one inbox with addresses like yourname+sandbox1@example.com. After you create a tester you can’t change the name, email or password, only the country or region.

A sandbox account can only test apps from the developer account that created it. Apple describes the steps in Create a Sandbox Apple Account.

How do I test in the sandbox on an iPhone?

Install a Debug build

Apps signed with a development profile (the Debug build you install with F5) and apps installed from TestFlight both use the sandbox. In the KXBuild view, select Debug under the Configuration node, run KXBuild: Build & Run and install the app on your phone. For debugging see Debug iOS apps on Windows with LLDB, breakpoints and crash logs.

When the app opens, the product list should show three products with prices. If it stays empty, find the Products not returned line in Output > KXBuild Console and work through the troubleshooting table at the end of this tutorial.

Sign in with the sandbox account

You don’t need to sign out of your own Apple Account on the phone.

  1. Tap a price button in the app. The system purchase sheet asks you to sign in. Enter the sandbox tester’s email and password.
  2. After you sign in, the purchase sheet shows [Environment: Sandbox], which means you’re connected to the sandbox. If that line is missing you’re connected to production, so don’t continue with the payment.
  3. Confirm the purchase. The app shows “Purchased”, and either the coins go up or the product changes to “Owned”.

According to Apple’s current documentation, after your first purchase attempt in a development-signed app, the sandbox account appears in Settings > Developer, where you can sign in, sign out or switch testers. Earlier iOS versions put it under Sandbox Account at the bottom of Settings > App Store. Check both places.

In Settings > Developer, tap the sandbox account and choose Manage to change the subscription renewal rate or clear purchase history. After you clear purchase history, sign out of the sandbox account and sign back in to clear the cache on the phone.

Test a TestFlight build

Apps installed from TestFlight always use the sandbox, and by default they buy in the sandbox with your real Apple Account, again without charging you.

To use a sandbox tester and the sandbox settings, first sign out in Settings > [your name] > Media & Purchases, then sign in to the sandbox account in Settings > Developer. Signing out of Media & Purchases can affect access to content you’ve bought, so a dedicated test device is best. For TestFlight distribution see TestFlight beta testing.

Test subscription renewals

Subscriptions renew faster in the sandbox. With the default “every 5 minutes” rate, a 1-month subscription renews every 5 minutes. Subscriptions renew automatically up to 12 times and turn off at the 13th renewal. You can change the rate in the sandbox tester settings in App Store Connect, or under Manage for the sandbox account on the phone. See Manage Sandbox Apple Account settings.

Subscription duration Every 3 minutes Every 5 minutes (default) Every 30 minutes Every hour
1 week 3 minutes 3 minutes 10 minutes 15 minutes
1 month 3 minutes 5 minutes 30 minutes 1 hour
2 months 6 minutes 10 minutes 1 hour 2 hours
3 months 9 minutes 15 minutes 1 hour 30 minutes 3 hours
6 months 18 minutes 30 minutes 3 hours 6 hours
1 year 36 minutes 1 hour 6 hours 12 hours

To test renewals, buy the monthly subscription and keep the app in the foreground. A few minutes later Transaction.updates receives the renewal. Set a breakpoint in handle or add print(transaction.productID, transaction.expirationDate ?? "") to see the new expiry date. After 12 renewals the subscription expires and the membership line goes back to “not subscribed”, which lets you test the expired state of your UI.

What should I check before submitting in-app purchases?

  • Your first in-app purchase or subscription must be submitted together with a new app version. Select them in the In-App Purchases and Subscriptions section of the version page, then submit. See Apple’s Submit an In-App Purchase.
  • Every product needs a review screenshot and notes. Products with incomplete information can’t be submitted.
  • App Review tests your purchase flow in the sandbox. Products that don’t load or buttons that do nothing get the app rejected. For common reasons see common App Store rejection reasons.
  • For building the Release .ipa and uploading it, see Publishing.

Why don’t products load or purchases fail?

Symptom Cause Fix
No products load The Paid Apps Agreement isn’t signed, or banking or tax information is incomplete, so the status isn’t Active Complete it under Business > Agreements and wait for Active
No products load The Bundle ID you built with doesn’t match the app record in App Store Connect Check Bundle Identifier on the General page of the Project Settings Editor
No products load You’re using a profile for a wildcard App ID Register an explicit App ID and generate a new Development profile
Some products don’t load The Product ID in code differs in spelling, case or spaces Copy the Product ID from App Store Connect and paste it into the code
Some products don’t load The product is missing a localized name or price Complete it on the product’s page
A new or just-edited product doesn’t load The sandbox syncs with a delay of up to about an hour Wait, then restart the app
Testers in one region get nothing The product isn’t available in the tester’s country or region Change the product’s availability, or change the tester’s region and sign in again
Subscriptions don’t load The subscription group or subscription has no display name Add localized information to both
The purchase sheet has no [Environment: Sandbox] You’re in production, for example running the App Store version Delete it and reinstall the Debug build with KXBuild
Purchases fail immediately Screen Time content restrictions block in-app purchases Allow in-app purchases in Settings > Screen Time
Sandbox sign-in fails or says the account is invalid The email is already an Apple Account, or the tester was created under another developer account Create a new tester with a fresh email in Users and Access > Sandbox of this account

If products still don’t load after working through the table, go back to Business > Agreements and confirm the Paid Apps Agreement shows Active. That is the most common blocker when adding in-app purchases. Once the agreement, products and code all check out, retry with a freshly created sandbox tester.

FAQ

What is the minimum iOS version for StoreKit 2?

Product, Transaction and AppStore.sync() in StoreKit 2 are all available from iOS 15. Apps with a lower deployment target have to use the original StoreKit API, or limit in-app purchases to devices on iOS 15 or later.

Where do I sign in with a sandbox tester on iPhone?

You don’t need to sign in beforehand. The first time you tap buy in a development-signed app, the system asks you to sign in, and you enter the sandbox tester account. According to Apple’s current documentation you then manage the sandbox account in Settings > Developer, and on earlier iOS versions at the bottom of Settings > App Store.

Why does Product.products(for:) return an empty array?

Most often the Paid Apps Agreement isn’t active yet. Next come Product IDs that don’t match App Store Connect, the wrong Bundle ID, or products missing a name or price. A newly created product can take up to an hour to appear in the sandbox.

How often do subscriptions renew in the sandbox?

At the default rate a 1-month subscription renews every 5 minutes and a 1-year subscription every hour, and renewals stop after 12. You can change the rate in App Store Connect or in the sandbox account settings on the phone.

What happens if I don’t call finish()?

The App Store treats the content as not delivered. The transaction comes back through Transaction.updates on the next launch, and unfinished transactions stay in Transaction.unfinished. Always call finish() after you deliver the content.