Start a project

Article · iOS · StoreKit · Monetisation · 13 min read

StoreKit 2 in-app purchases for indie apps

StoreKit 2 is the current StoreKit API for in-app purchases and subscriptions. You fetch Product values, call purchase(), listen to Transaction.updates, finish each verified transaction, and decide what the user has unlocked from Transaction.currentEntitlements — not from a Boolean you set on a successful checkout and then forget to update. For a small indie app, that on-device flow is usually enough. A server and the App Store Server API are optional, not a prerequisite for a first paid feature.

On this page 10 sections
  1. What StoreKit 2 changed, in practice
  2. Product types and how to model entitlements
  3. Fetching products and making a purchase
  4. Listening for transactions and finishing them
  5. Subscriptions, grace periods and family sharing
  6. Testing with StoreKit configuration files and TestFlight
  7. Receipts, App Store Server API, and what a small app can skip
  8. Review, privacy and the paid-apps agreement
  9. Key takeaways
  10. Frequently asked questions

This is an implementation guide, not a launch-process walkthrough or a pricing essay. For the wider release steps see from build to App Store; for whether a purchase is even the right business model, see what an iOS app really costs. Product IDs, agreements and review still have to be in place before any of the code below matters.

What StoreKit 2 changed, in practice

StoreKit 1 centred on SKPaymentQueue, receipts you had to parse yourself, and observers you could miss if they were not registered at launch. StoreKit 2, available from iOS 15, changes the daily work in a few concrete ways:

You can still use some StoreKit 1 types, but a new indie app should not. If you are migrating, move one product at a time and do not finish the same transaction in both APIs.

Product types and how to model entitlements

Create products in App Store Connect, and use the same identifiers in a StoreKit configuration file for local testing. The types you will actually choose between are:

Model your own entitlement as something derived, not as a UserDefaults flag set in the purchase button:

import Foundation
import StoreKit

enum Entitlement: Hashable {
    case pro
    case extraExportPacks(Int)
}

struct EntitlementState: Equatable {
    var hasPro = false
    var exportCredits = 0
}

func entitlements(from transactions: [Transaction], storedCredits: Int) -> EntitlementState {
    var state = EntitlementState(exportCredits: storedCredits)
    for transaction in transactions {
        switch transaction.productID {
        case "uk.leej.example.pro":
            state.hasPro = true
        default:
            break
        }
    }
    return state
}

Consumable credits still need storage you own, because the finished transaction will not sit in currentEntitlements as a count. Keep that storage on the device (or on your server if you have one), and update it only after a verified, unfinished transaction for that product. Non-consumables and subscriptions should be re-derived every time you iterate current entitlements, so a refund can take Pro away.

Keep product IDs in one list the app and the configuration file share. A typo will not crash; it will just return an empty product list, which looks like a storefront failure.

Fetching products and making a purchase

Load products when you show the paywall, and handle an empty list as a first-class state — not as a blank screen with a frozen button.

import StoreKit

final class Store {
    let productIDs: [String] = [
        "uk.leej.example.pro",
        "uk.leej.example.exports.10"
    ]

    func loadProducts() async throws -> [Product] {
        let products = try await Product.products(for: productIDs)
        return products.sorted { $0.displayName < $1.displayName }
    }

    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await apply(transaction)
            await transaction.finish()
            return transaction
        case .userCancelled:
            return nil
        case .pending:
            // Ask to Buy, or a deferred payment. Do not grant yet.
            return nil
        @unknown default:
            return nil
        }
    }

    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw error
        case .verified(let value):
            return value
        }
    }
}

Product.purchase() presents the system sheet. You do not style that sheet, and you should not try to replace it. Use product.displayPrice on your own button so the price the user sees before the sheet matches the storefront.

A few purchase options exist (app account tokens, quantity for consumables, promotional offers). For a first version, skip promotions and quantity until the basic success, cancel and pending paths are tested. If you do pass an appAccountToken, use a UUID you can recognise later when a server notification arrives — do not invent a format you cannot parse.

Always finish after you have applied the entitlement. If apply can fail, do not finish, so StoreKit will offer the transaction again. If apply is a local flag flip and cannot reasonably fail, finish immediately afterwards.

Listening for transactions and finishing them

If you only handle the purchase() return value, you will miss renewals, Family Sharing, purchases from another device, Ask to Buy approvals, and anything that finished in a previous session but was not finish()ed. Start a listener at launch, and run it for the life of the process.

func listenForTransactions() -> Task<Void, Never> {
    Task.detached {
        for await update in Transaction.updates {
            do {
                let transaction = try self.checkVerified(update)
                await self.apply(transaction)
                await transaction.finish()
            } catch {
                // Leave unfinished so it can be retried. Log the verification failure.
            }
        }
    }
}

On launch, also iterate Transaction.currentEntitlements and Transaction.unfinished, apply, and finish the unfinished ones you have successfully applied. That covers a crash between grant and finish().

Verification can fail. Treat .unverified as “do not grant”. Do not try to “make do” with the unverified payload just because it looks right in a decoder. If you are curious about a test JWS, paste it into the JWT decoder or check a signature with the JWT / JWKS verifier; that is a debugging aid, not a substitute for VerificationResult in the app.

apply should be idempotent. A transaction id you have already seen should not add a second pile of credits. Store the processed transaction ids for consumables, and let non-consumables simply refresh from current entitlements.

Subscriptions, grace periods and family sharing

For auto-renewable subscriptions, current entitlements tell you whether there is a transaction that still entitles the user. For the richer status — billing retry, grace period, expired, revoked — use Product.subscription and Product.SubscriptionInfo.status.

A practical policy for a small app:

Renewals arrive through Transaction.updates. You do not schedule them. If you need to explain to yourself when a test subscription will renew in a local StoreKit file, you can shorten the renewal period in the configuration; do not confuse that with real-world billing. A cron explainer will not drive StoreKit, and you should not invent your own renewal cron on device. The App Store is the scheduler.

Offer codes, promotional offers and win-back offers are layerable later. Each has its own StoreKit 2 API and its own App Store Connect setup. Get the ordinary subscribe, renew, expire and restore paths right first.

Testing with StoreKit configuration files and TestFlight

Test in this order: a local StoreKit configuration file in Xcode, then a sandbox Apple ID on a device, then TestFlight.

A StoreKit configuration file sits in the project and lists products, prices, and renewal timings for subscriptions. You can:

Point the scheme at the configuration file for local runs, and take it away for TestFlight and production, where the real storefront is the source of products. If the file is still active, you will not see your live products, which is a common “empty paywall on a device that worked in the simulator” report.

Sandbox and TestFlight use App Store Connect products, a sandbox tester or a TestFlight account, and real product identifiers. Purchases are still not charged as production. You will need the Paid Apps Agreement, banking and tax forms to be in place for sandbox to behave; an incomplete agreement is a frequent reason products fail to load on a device.

Test at least these cases, not only the happy path:

You do not need a screenshot of every case for Review, but you do need the returning-customer path to work.

Receipts, App Store Server API, and what a small app can skip

StoreKit 1 taught everyone to upload the receipt to a server and to verify it with Apple. StoreKit 2 does not require that for a correct client. Signed transactions and currentEntitlements are already the replacement for most of that work on the device.

Add a server when you have something a device-only model cannot do honestly:

The App Store Server API and server notifications v2 send signed payloads. If you go down that road, verify them properly (the JWKS verifier on this site is a way to understand the shape of that job) and treat the payloads as the source of truth for account-level entitlements. Do not half-verify a JWS and store it.

A small utility that unlocks a feature on the same device can skip all of this. Skipping it is not a shortcut if you do not have an account system; it is the appropriate design. You can add a server later without changing product IDs.

AppTransaction.shared exists for the signed app transaction — useful if you need to know the original purchase date of a paid app, or to reason about a bundle — and is separate from in-app entitlements. Most freemium apps will not need it on day one.

Review, privacy and the paid-apps agreement

None of the client code ships until the business side is in place. Apple’s App Review Guidelines, especially the payments section, and the Paid Apps agreement in App Store Connect are the source of truth; both can change, so read them again before you submit.

A short list of things that regularly hold up a first in-app purchase, in my experience:

Write a Review note that says which sandbox account to use, which button starts the purchase, and whether a configuration file is only for local builds (it should not be in the Review path). Keep the note factual. The rest of a clean submission is the same launch discipline as any other release.

Key takeaways

If you want help wiring a first StoreKit 2 paywall in a small app, tell me about it.

Frequently asked questions

What is the difference between StoreKit 1 and StoreKit 2?

StoreKit 2, from iOS 15, uses async Product and Transaction APIs, signed transactions, Transaction.currentEntitlements and Transaction.updates, instead of SKPaymentQueue and manual receipt parsing. New work should use StoreKit 2. You do not need to ship both.

How do you test in-app purchases in Xcode?

Add a StoreKit configuration file to the project, list the same product IDs you will use in App Store Connect, and point your scheme at it. You can purchase, renew, refund and fail billing locally with the Xcode transaction UI, without a sandbox Apple ID. Remove the configuration for TestFlight and production so the real storefront is used.

How do you unlock features after a StoreKit 2 purchase?

Verify the transaction, grant the entitlement, then call finish(). For a lifetime or subscription product, also recompute access from Transaction.currentEntitlements on launch and whenever updates arrive, so a refund or a purchase on another device is reflected. Do not rely only on a flag set in the button handler.

How do you handle subscription renewals and expiration?

Renewals arrive on Transaction.updates. Recompute entitlements from currentEntitlements, and use Product.SubscriptionInfo.status if you need grace period or billing-retry detail. Keep access on during a grace period if you can; remove it when the entitlement is no longer current. Do not schedule renewals yourself.

What does App Review check for in-app purchases?

They try the purchase, and they look for a complete Paid Apps Agreement, honest prices and subscription terms, a way to restore, and privacy disclosures that match the app. They also catch digital goods that should use StoreKit and do not. Read the current payments section of the App Review Guidelines before you submit, and include a sandbox account in your Review notes.

Written by Lee Johnson. Published 10 November 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project