StoreKit 2: In-App Purchases and Subscriptions with Modern Swift APIs
The original StoreKit API shipped with iOS 3. It worked through delegate callbacks, receipt validation was a pain, and
determining whether a user was actually entitled to a product required parsing ASN.1 receipts or maintaining your own
server. StoreKit 2, introduced in iOS 15, replaced all of that with async/await APIs, server-signed JWS transactions,
and a Transaction.currentEntitlements sequence that tells you exactly what a user owns — no receipt parsing required.
This post covers the full StoreKit 2 purchase lifecycle: fetching products, executing purchases, verifying transactions,
checking entitlements, building subscription UIs with SubscriptionStoreView, and the new Advanced Commerce API in iOS
26 for large catalogs. We won’t cover App Store Connect product configuration or server-side receipt validation — those
are operational concerns outside the client SDK.
Note: StoreKit 2 requires iOS 15+. The
SubscriptionStoreViewandSubscriptionOfferViewAPIs require iOS 17+. The Advanced Commerce API requires iOS 26+. All code in this post uses Swift 6 strict concurrency.
Contents
- The Problem
- Fetching Products
- The Purchase Flow
- Transaction Verification and Listening
- Entitlement Checking
- Subscription Management
- SubscriptionStoreView and Offer Views
- Advanced Commerce API (iOS 26)
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a Pixar film streaming app that offers a monthly subscription and individual film purchases. With the original StoreKit, fetching products and handling purchases looked something like this:
// Original StoreKit — delegate-based, no structured concurrency
final class PixarStoreManager: NSObject, SKProductsRequestDelegate,
SKPaymentTransactionObserver {
var products: [SKProduct] = []
func fetchProducts() {
let request = SKProductsRequest(productIdentifiers: [
"com.pixarstream.monthly",
"com.pixarstream.film.toystory"
])
request.delegate = self
request.start()
}
func productsRequest(
_ request: SKProductsRequest,
didReceive response: SKProductsResponse
) {
products = response.products
}
func paymentQueue(
_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]
) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// Parse the receipt? Validate on server? Both?
queue.finishTransaction(transaction)
case .failed:
queue.finishTransaction(transaction)
default:
break
}
}
}
}
The issues compound in production. You need to manually add and remove the transaction observer. Receipt validation
requires either a server roundtrip or local ASN.1 parsing. Determining subscription status means decoding receipt fields
and comparing dates. Restoring purchases requires a separate SKPaymentQueue.restoreCompletedTransactions() call. None
of this is async/await-compatible, and error handling is scattered across multiple delegate callbacks.
StoreKit 2 collapses all of this into a handful of async calls with typed results.
Fetching Products
Apple Docs:
Product— StoreKit
The entry point is
Product.products(for:). Pass your
product identifiers and you get back an array of Product values — a value type with all the metadata you need for
display:
import StoreKit
@Observable @MainActor
final class PixarStore {
private(set) var films: [Product] = []
private(set) var subscriptions: [Product] = []
private(set) var isLoading = false
private let filmIDs: Set<String> = [
"com.pixarstream.film.toystory",
"com.pixarstream.film.findingNemo",
"com.pixarstream.film.insideOut",
"com.pixarstream.film.coco"
]
private let subscriptionIDs: Set<String> = [
"com.pixarstream.monthly",
"com.pixarstream.annual"
]
func loadProducts() async {
isLoading = true
defer { isLoading = false }
do {
let allProducts = try await Product.products(
for: filmIDs.union(subscriptionIDs)
)
films = allProducts
.filter { $0.type == .consumable || $0.type == .nonConsumable }
.sorted { $0.price < $1.price }
subscriptions = allProducts
.filter { $0.type == .autoRenewable }
.sorted { $0.price < $1.price }
} catch {
// Product fetch failures are usually network-related.
// Log and let the UI show an empty state.
print("Failed to fetch products: \(error)")
}
}
}
Product exposes displayName, description, displayPrice (already formatted for the user’s locale), price (a raw
Decimal), and type (.consumable, .nonConsumable, .autoRenewable, .nonRenewable). You never need to format
currency manually.
Tip: Use a
StoreKit Configuration Filein Xcode for local testing. It lets you define products without an App Store Connect account and test the full purchase flow in the simulator, including subscription renewals on an accelerated timeline.
The Purchase Flow
Apple Docs:
Product.purchase(options:)— StoreKit
Purchasing a product is a single async call that returns a Product.PurchaseResult:
extension PixarStore {
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerification(verification)
await transaction.finish()
await refreshEntitlements()
return transaction
case .userCancelled:
return nil
case .pending:
// Transaction requires approval (Ask to Buy, SCA, etc.)
return nil
@unknown default:
return nil
}
}
private func checkVerification<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .verified(let safe):
return safe
case .unverified(_, let error):
throw StoreError.verificationFailed(error)
}
}
}
enum StoreError: LocalizedError {
case verificationFailed(Error)
var errorDescription: String? {
switch self {
case .verificationFailed(let underlying):
return "Transaction verification failed: \(underlying.localizedDescription)"
}
}
}
Three things to note here. First, the purchase result wraps the transaction in a VerificationResult. StoreKit 2
automatically verifies the JWS signature on the device — you get either .verified (the transaction is
cryptographically valid) or .unverified (something is wrong). Second, you must call transaction.finish() to
acknowledge the transaction. Unfinished transactions will be re-delivered on the next app launch. Third, the .pending
case handles Ask to Buy (parental approval) and Strong Customer Authentication (EU payment regulation). Your UI should
show an appropriate “waiting for approval” state.
Purchase Options
You can pass options to customize the purchase. The most common is appAccountToken, which lets you associate a
purchase with a specific user account in your backend:
func purchase(
_ product: Product,
for userID: UUID
) async throws -> Transaction? {
let result = try await product.purchase(options: [
.appAccountToken(userID)
])
// Handle result as shown above
}
This token persists through the transaction’s lifetime and appears in server-to-server notifications — invaluable for matching App Store transactions to your user database.
Transaction Verification and Listening
Apple Docs:
Transaction— StoreKit
StoreKit 2 transactions are JWS (JSON Web Signature) payloads signed by Apple. The framework verifies them on-device
automatically. You should still handle the .unverified case defensively — it can occur if the device clock is
significantly wrong or if the binary has been tampered with.
The critical pattern for production apps is listening for transaction updates on app launch. Transactions can arrive at any time: a pending purchase gets approved, a subscription renews in the background, or a refund is processed:
extension PixarStore {
/// Call once on app launch — typically in the App init or a task modifier.
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await result in Transaction.updates {
guard let self else { return }
do {
let transaction = try self.checkVerification(result)
await transaction.finish()
await self.refreshEntitlements()
} catch {
// Log verification failures but don't crash.
print("Transaction update verification failed: \(error)")
}
}
}
}
}
Warning:
Transaction.updatesonly delivers transactions that arrive after your listener starts. It does not replay historical transactions. For initial state on launch, useTransaction.currentEntitlements(see next section).
Store the returned Task so you can cancel it if needed — though in most apps, you want this listener running for the
entire app lifecycle.
Entitlement Checking
Apple Docs:
Transaction.currentEntitlements— StoreKit
Entitlement checking is where StoreKit 2 truly shines compared to its predecessor. Transaction.currentEntitlements is
an AsyncSequence that yields every product the user is currently entitled to — active subscriptions, non-consumable
purchases, and non-renewing subscriptions that haven’t expired:
extension PixarStore {
@Observable @MainActor
final class Entitlements {
var ownedFilmIDs: Set<String> = []
var activeSubscription: Transaction?
var subscriptionTier: SubscriptionTier = .none
}
enum SubscriptionTier: Comparable {
case none
case monthly
case annual
}
func refreshEntitlements() async {
var ownedFilms: Set<String> = []
var latestSubscription: Transaction?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
switch transaction.productType {
case .nonConsumable:
ownedFilms.insert(transaction.productID)
case .autoRenewable:
if let latest = latestSubscription {
if transaction.purchaseDate > latest.purchaseDate {
latestSubscription = transaction
}
} else {
latestSubscription = transaction
}
default:
break
}
}
entitlements.ownedFilmIDs = ownedFilms
entitlements.activeSubscription = latestSubscription
entitlements.subscriptionTier = tierFor(
latestSubscription?.productID
)
}
private func tierFor(_ productID: String?) -> SubscriptionTier {
switch productID {
case "com.pixarstream.annual": return .annual
case "com.pixarstream.monthly": return .monthly
default: return .none
}
}
}
Call refreshEntitlements() on app launch (after your transaction listener is set up) and after every successful
purchase. The combination of Transaction.currentEntitlements for initial state and Transaction.updates for real-time
changes gives you a complete entitlement picture without touching a receipt.
Checking Entitlements in Views
With the Entitlements object exposed as an @Observable property, gating content in SwiftUI is straightforward:
struct FilmPlayerView: View {
let film: PixarFilm
var store: PixarStore
var body: some View {
if store.entitlements.ownedFilmIDs.contains(film.productID)
|| store.entitlements.subscriptionTier >= .monthly {
VideoPlayerView(url: film.streamURL)
} else {
PurchasePromptView(film: film, store: store)
}
}
}
Subscription Management
Apple Docs:
Product.SubscriptionInfo— StoreKit
For auto-renewable subscriptions, Product.SubscriptionInfo provides subscription-specific metadata. You access it
through product.subscription:
func loadSubscriptionStatus() async {
guard let subscription = subscriptions.first,
let info = subscription.subscription else { return }
// Subscription group ID — all products in the same group
let groupID = info.subscriptionGroupID
// Check current subscription status for this group
guard let statuses = try? await info.status else { return }
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo,
case .verified(let transaction) = status.transaction
else { continue }
let isActive = status.state == .subscribed
|| status.state == .inGracePeriod
let willRenew = renewalInfo.willAutoRenew
let expirationDate = transaction.expirationDate
print("Active: \(isActive), renews: \(willRenew)")
if let expiration = expirationDate {
print("Expires: \(expiration)")
}
}
}
The status.state enum covers every subscription lifecycle state: .subscribed, .expired, .inBillingRetryPeriod,
.inGracePeriod, and .revoked. This is dramatically simpler than decoding these states from a receipt.
SubscriptionStoreView and Offer Views
Apple Docs:
SubscriptionStoreView— StoreKit
iOS 17 introduced SubscriptionStoreView — a pre-built, customizable paywall view that handles product display,
purchase flow, and even subscription management UI:
import StoreKit
struct PixarPaywallView: View {
let groupID: String // Your subscription group ID
var body: some View {
SubscriptionStoreView(groupID: groupID) {
VStack(spacing: 16) {
Image(systemName: "film.stack")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text("Unlock the Pixar Vault")
.font(.title.bold())
Text("Stream every Pixar film, from Toy Story to the latest release.")
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding()
}
.subscriptionStoreControlStyle(.prominentPicker)
.subscriptionStoreButtonLabel(.multiline)
.storeButton(.visible, for: .restorePurchases)
}
}
SubscriptionStoreView reads product information and pricing directly from App Store Connect. It handles localization,
pricing display, the purchase sheet, and error states. You provide the marketing content — the header area — and the
system handles the rest.
SubscriptionOfferView
For win-back and promotional offers, use SubscriptionOfferView to present targeted offers to eligible users:
@available(iOS 18, *)
struct PixarWinBackView: View {
let groupID: String
var body: some View {
SubscriptionStoreView(groupID: groupID)
.winBackOffer { offer in
VStack {
Text("We miss you at the Pixar Vault!")
.font(.headline)
Text(offer.displayPrice)
.font(.title2.bold())
Text("for \(offer.period.debugDescription)")
.font(.subheadline)
}
}
}
}
Tip: Win-back offers are configured in App Store Connect. The
winBackOffermodifier only appears when the system determines the user is eligible — you don’t need to check eligibility yourself.
Advanced Commerce API (iOS 26)
Apple Docs:
ProductStore— StoreKit
iOS 26 introduces the Advanced Commerce API for apps with large or dynamic product catalogs — think a Pixar collectibles marketplace with hundreds of digital items that change seasonally. The traditional approach of hardcoding product identifiers breaks down at scale.
The new ProductStore protocol and CommerceSession allow you to define products dynamically and handle complex
purchase flows that don’t map cleanly to the standard App Store product model:
@available(iOS 26, *)
struct PixarCollectibleStore {
/// Fetch products from your own catalog service,
/// then resolve them through the Advanced Commerce API.
func fetchCollectibles() async throws -> [Product] {
let catalogItems = try await PixarCatalogService.fetchCurrentSeason()
let productIDs = Set(catalogItems.map(\.appStoreProductID))
let products = try await Product.products(for: productIDs)
return products
}
/// Use CommerceSession for multi-step purchase flows
func purchaseBundle(items: [PixarCollectible]) async throws {
for item in items {
let products = try await Product.products(
for: [item.appStoreProductID]
)
guard let product = products.first else { continue }
let result = try await product.purchase(options: [
.appAccountToken(item.ownerAccountID)
])
if case .success(let verification) = result,
case .verified(let transaction) = verification {
await transaction.finish()
try await PixarCatalogService.confirmPurchase(
collectibleID: item.id,
transactionID: transaction.id
)
}
}
}
}
The Advanced Commerce API is designed for apps whose catalogs are too large or too dynamic for App Store Connect’s product management UI. If your app has fewer than a few hundred products and they don’t change frequently, standard StoreKit 2 is sufficient. The Advanced Commerce API adds complexity that’s only justified at scale.
Note: The Advanced Commerce API requires an entitlement from Apple. You must apply for access through App Store Connect. This is not a general-availability API — it targets specific use cases like digital marketplaces and large-scale content platforms.
Performance Considerations
Product fetch latency. Product.products(for:) makes a network call to the App Store. On a cold launch, this can
take 500ms-2s depending on network conditions. Fetch products early — ideally in a background task triggered by .task
on your root view — and cache the results in your @Observable store. Products don’t change mid-session, so a single
fetch per app launch is sufficient.
Transaction iteration. Transaction.currentEntitlements iterates over all verified transactions on device. For most
apps (single-digit products), this is near-instant. If your app has hundreds of products, the iteration itself is
negligible, but the verification step for each transaction involves JWS signature checking. Profile with Instruments if
you see latency on launch.
Transaction.updates memory. The async sequence holds a reference to the StoreKit subsystem. The Task you create
for listening should live for the app’s lifetime — store it in your App struct or a long-lived manager. Creating and
canceling multiple listeners is wasteful.
Subscription status checks. Product.SubscriptionInfo.status makes a server call. Don’t poll it repeatedly. Check
on launch and after relevant transaction events. Cache the result in your observable state.
// Efficient product loading — cache and don't refetch
@Observable @MainActor
final class PixarStoreCached {
private var hasLoadedProducts = false
func loadProductsIfNeeded() async {
guard !hasLoadedProducts else { return }
await loadProducts()
hasLoadedProducts = true
}
}
Apple Docs:
Transaction.updates— StoreKit
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| New app targeting iOS 15+ | Use StoreKit 2 exclusively. The async APIs are cleaner and transactions are auto-verified. |
| Existing app with original StoreKit | Migrate incrementally. StoreKit 1 and 2 coexist. Use StoreKit 2 for new flows. |
| Subscription paywall UI (iOS 17+) | Use SubscriptionStoreView. Don’t rebuild the purchase sheet yourself. |
| Catalog with 500+ dynamic products | Investigate the Advanced Commerce API (iOS 26). |
| Server-side entitlement validation | Send originalTransactionId to your server. Verify via the App Store Server API. |
| Consumable products with high volume | currentEntitlements excludes finished consumables. Track balances in your backend. |
Summary
Product.products(for:)replacesSKProductsRequestwith a single async call that returns typedProductvalues with locale-formatted pricing.product.purchase()returns aPurchaseResultwith.success,.userCancelled, and.pendingcases — no delegate callbacks, no transaction observer boilerplate.- All transactions are JWS-signed and automatically verified on device. Handle
.unverifieddefensively but expect it to be rare. Transaction.currentEntitlementsgives you the complete entitlement picture on launch.Transaction.updatesdelivers real-time changes. Together they replace receipt parsing entirely.SubscriptionStoreView(iOS 17+) provides a production-ready paywall UI. Customize the marketing header and let the system handle pricing, localization, and purchase flow.- The Advanced Commerce API (iOS 26) targets apps with large, dynamic catalogs that exceed standard App Store Connect product management.
Ready to build a complete paywall from scratch? Head to Build an In-App Purchase Paywall with StoreKit 2 for a step-by-step tutorial that wires up products, purchases, and entitlement gating in a real app.