- Published on
- 8 min read
> StoreKit 2 for In-App Purchases and Subscriptions
StoreKit 2, introduced at WWDC 2021, completely rethought how iOS apps handle in-app purchases. The original StoreKit framework relied on delegates, observer patterns, and manual receipt validation. StoreKit 2 replaces all of that with async/await APIs, automatic transaction verification through JWS signatures, and a much cleaner data model. If you're starting a new project or modernizing an existing one, StoreKit 2 is the way to go.
Loading Products
Everything starts with Product. You define product identifiers in App Store Connect, then fetch them at runtime:
import StoreKit
func fetchProducts() async throws -> [Product] {
let productIDs = [
"com.myapp.premium",
"com.myapp.monthly",
"com.myapp.yearly"
]
return try await Product.products(for: productIDs)
}
Each Product carries everything you need to display it: displayName, description, displayPrice (already localized for the user's region), and the numeric price. The type property tells you whether it's a .consumable, .nonConsumable, .autoRenewable, or .nonRenewable product.
Making a Purchase
Purchasing a product is a single async call. The result tells you exactly what happened:
import StoreKit
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verification.payloadValue
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
// Ask to Buy or deferred payment
return nil
@unknown default:
return nil
}
}
The .success case wraps the transaction in a VerificationResult. StoreKit automatically validates the JWS signature from Apple's servers, so calling payloadValue either returns a verified transaction or throws if the signature doesn't check out. No more manual receipt validation.
Calling transaction.finish() is important. It tells the system you've delivered the content. If you don't finish a transaction, StoreKit will keep delivering it through Transaction.updates until you do.
Listening for Transaction Updates
Not every transaction happens while the user is actively tapping a "Buy" button. Subscriptions renew in the background, Family Sharing can grant or revoke access, Ask to Buy requests get approved later, and users can request refunds. You need to listen for all of these.
Start a Transaction.updates listener when your app launches:
import StoreKit
@MainActor
class StoreManager: ObservableObject {
@Published private(set) var purchasedProductIDs: Set<String> = []
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = Task {
for await update in Transaction.updates {
if let transaction = try? update.payloadValue {
await self.refreshPurchasedProducts()
await transaction.finish()
}
}
}
Task {
await refreshPurchasedProducts()
}
}
deinit {
updatesTask?.cancel()
}
func refreshPurchasedProducts() async {
var purchased: Set<String> = []
for await entitlement in Transaction.currentEntitlements {
if let transaction = try? entitlement.payloadValue {
purchased.insert(transaction.productID)
}
}
self.purchasedProductIDs = purchased
}
}
Transaction.currentEntitlements is your source of truth for what the user currently owns. It includes active subscriptions and non-consumable purchases that haven't been refunded. Consumables don't appear here since they're meant to be used up.
Checking Subscription Status
For auto-renewable subscriptions, you'll often need more detail than just "active or not." The subscription status API gives you the full picture:
import StoreKit
func checkSubscriptionStatus(for product: Product) async throws -> Bool {
guard let statuses = try await product.subscription?.status else {
return false
}
for status in statuses {
switch status.state {
case .subscribed, .inGracePeriod:
return true
case .expired, .revoked:
continue
case .inBillingRetryPeriod:
// Payment failed but Apple is retrying.
// You could still grant access here depending on your policy.
continue
default:
continue
}
}
return false
}
The RenewalState tells you exactly where the subscription stands. .inGracePeriod means billing failed but you've enabled a grace period in App Store Connect, so the user should still have access. .inBillingRetryPeriod means Apple is still trying to charge the user. Whether you grant access during retry is your call.
You can also dig into status.renewalInfo to check autoRenewalStatus (whether the user has turned off auto-renew), gracePeriodExpirationDate, and on iOS 17+, nextRenewalDate.
SwiftUI StoreKit Views
Starting with iOS 17, Apple added SwiftUI views that handle product display and purchasing out of the box. These save a lot of work if you don't need a fully custom UI.
SubscriptionStoreView is the big one for subscriptions. You pass it a subscription group ID and it builds the entire merchandising experience:
import SwiftUI
import StoreKit
struct PaywallView: View {
var body: some View {
SubscriptionStoreView(groupID: "598392E1") {
VStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.system(size: 48))
.foregroundStyle(.yellow)
Text("Unlock Premium")
.font(.title.bold())
Text("Get access to all features with a subscription.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding()
}
.subscriptionStoreControlStyle(.buttons)
.subscriptionStoreButtonLabel(.multiline)
}
}
The group ID comes from App Store Connect (or your StoreKit configuration file for testing). The view handles loading products, displaying prices, and processing purchases automatically.
For individual products, there's ProductView and StoreView:
import SwiftUI
import StoreKit
struct StorePageView: View {
let productIDs = [
"com.myapp.smalltip",
"com.myapp.mediumtip",
"com.myapp.largetip"
]
var body: some View {
StoreView(ids: productIDs)
.productViewStyle(.large)
.storeButton(.visible, for: .restorePurchases)
}
}
The .storeButton(.visible, for: .restorePurchases) modifier adds a restore purchases button, which Apple still expects to see in your app.
Putting It All Together
Here's a more complete store manager that handles both one-time purchases and subscriptions:
import StoreKit
@MainActor
class StoreManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private let productIDs = [
"com.myapp.premium",
"com.myapp.monthly",
"com.myapp.yearly"
]
private var updatesTask: Task<Void, Never>?
var isPremium: Bool {
!purchasedProductIDs.isEmpty
}
init() {
updatesTask = Task {
for await update in Transaction.updates {
if let transaction = try? update.payloadValue {
await refreshPurchasedProducts()
await transaction.finish()
}
}
}
Task {
await fetchProducts()
await refreshPurchasedProducts()
}
}
deinit {
updatesTask?.cancel()
}
func fetchProducts() async {
do {
products = try await Product.products(for: productIDs)
.sorted { $0.price < $1.price }
} catch {
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async throws -> Bool {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verification.payloadValue
await refreshPurchasedProducts()
await transaction.finish()
return true
case .userCancelled, .pending:
return false
@unknown default:
return false
}
}
func refreshPurchasedProducts() async {
var purchased: Set<String> = []
for await entitlement in Transaction.currentEntitlements {
if let transaction = try? entitlement.payloadValue {
purchased.insert(transaction.productID)
}
}
purchasedProductIDs = purchased
}
func restorePurchases() async throws {
try await AppStore.sync()
await refreshPurchasedProducts()
}
}
You'd inject this into your SwiftUI views as an environment object:
import SwiftUI
@main
struct MyApp: App {
@StateObject private var store = StoreManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}
Testing with StoreKit Configuration Files
You don't need an App Store Connect account to start testing. Xcode's StoreKit configuration files let you define products locally and test the entire purchase flow in the simulator.
- Create a new file in Xcode (File > New > File) and choose "StoreKit Configuration File"
- Add your products with the same identifiers you plan to use in App Store Connect
- Go to your scheme settings (Product > Scheme > Edit Scheme), select Run > Options, and set the StoreKit Configuration to your file
With that in place, all StoreKit calls hit the local configuration instead of Apple's servers. You can test purchases, subscriptions, renewals, cancellations, and refunds without spending real money or waiting for sandbox delays.
The Transaction Manager (Debug > StoreKit > Manage Transactions) lets you view and manipulate transactions while debugging. You can delete transactions to reset state, issue refunds, and for subscriptions, the renewal rate is accelerated so you're not waiting around for monthly cycles.
For automated tests, the StoreKitTest framework gives you programmatic control:
import StoreKitTest
import Testing
@Test func testPurchaseFlow() async throws {
let session = try SKTestSession(configurationFileNamed: "Store")
session.clearTransactions()
let products = try await Product.products(for: ["com.myapp.premium"])
let product = try #require(products.first)
let result = try await product.purchase()
guard case .success(let verification) = result else {
Issue.record("Expected successful purchase")
return
}
let transaction = try verification.payloadValue
#expect(transaction.productID == "com.myapp.premium")
await transaction.finish()
}
What's New in Recent Releases
StoreKit 2 has evolved significantly since its introduction. iOS 17 brought the SwiftUI views covered above, plus useful transaction metadata like storefront and reason (distinguishing .purchase from .renewal).
iOS 18 officially deprecated the original StoreKit framework and added win-back offers for re-engaging lapsed subscribers. It also introduced new SubscriptionStoreView control styles like .compactPicker and .pagedPicker for different subscription presentation layouts.
Most recently, iOS 26 added SubscriptionOfferView for merchandising upgrade, downgrade, and crossgrade offers within a subscription group, plus expanded offer code support to consumables and non-consumables.
For more details on these updates, check Apple's StoreKit documentation and the yearly WWDC sessions on what's new in StoreKit.
// Continue_Learning
Reading Your App's Age Rating with StoreKit's ageRatingCode
Learn how to use AppStore.ageRatingCode to read your app's current age rating and react to rating changes for parental consent compliance.
Scheduling Alarms with AlarmKit
Learn how to use AlarmKit to schedule alarms that appear in the system Clock app, giving your app native alarm integration on iOS 26.
Finding the Top View Controller in Swift
How to traverse the view controller hierarchy to find the currently visible view controller.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.