Why StoreKit 2 Changes Everything for iOS Monetization
If you've ever wrestled with the original StoreKit framework, you know the pain. Convoluted delegate callbacks, opaque receipt validation, and a general feeling that Apple built the API in a different era. StoreKit 2 rewrites all of that — and honestly, it's about time.
It leverages Swift concurrency with async/await, provides cryptographically signed transactions in JSON Web Signature format, and ships SwiftUI views that can render a complete paywall in a single line of code. That last part still blows my mind a little.
With the latest updates from WWDC 2025 and iOS 26, StoreKit 2 has gained even more power: the new SubscriptionOfferView for merchandising, expanded offer codes for all purchase types, win-back offers for re-engaging churned subscribers, and the appTransactionID field for precise customer attribution. This guide walks you through every piece you need to ship a production-ready in-app purchase system — from project setup to testing — all updated for Xcode 26 and Swift 6.2.
Setting Up Your Xcode Project
Before writing any code, you'll need to configure your Xcode project with the right capabilities and a local StoreKit configuration file for testing. It's pretty straightforward, but getting it wrong will leave you scratching your head later.
Adding the In-App Purchase Capability
Open your project in Xcode 26, select your target, go to the Signing & Capabilities tab, and click + Capability. Search for In-App Purchase and add it. This registers your app with the App Store for handling transactions.
Creating a StoreKit Configuration File
A StoreKit configuration file lets you define products locally so you can test purchases without connecting to App Store Connect. This is a huge time saver during development.
Go to File → New → File and choose StoreKit Configuration File. Name it something like Products.storekit.
Inside the configuration file, add your products. For this guide, we'll create three products:
- Consumable:
com.app.coins.100— 100 Gold Coins - Non-Consumable:
com.app.premium.unlock— Premium Unlock - Auto-Renewable Subscription:
com.app.pro.monthlyandcom.app.pro.yearlyin a subscription group called "Pro Access"
Enabling the Configuration in Your Scheme
Here's the part people miss — creating the file isn't enough. You have to tell Xcode to actually use it.
Edit your scheme (Product → Scheme → Edit Scheme), select Run on the left, go to the Options tab, and set the StoreKit Configuration dropdown to your Products.storekit file. Without this step, your app will try to contact the App Store and come back empty-handed.
Understanding In-App Purchase Types
StoreKit 2 supports four distinct purchase types, each suited for different monetization strategies:
- Consumables — Products that can be purchased multiple times, like in-game currency or extra lives. Each purchase is a separate transaction that you must track and finish.
- Non-Consumables — One-time purchases that permanently unlock content or features. Once bought, they show up in the user's entitlements across all devices.
- Auto-Renewable Subscriptions — Recurring charges that grant access to premium content. The App Store handles renewal, grace periods, and billing retry for you.
- Non-Renewing Subscriptions — Time-limited access that doesn't renew automatically. Your app is responsible for tracking expiration (which is why most developers avoid these).
Building the Store Manager
The foundation of any StoreKit 2 integration is a central manager that fetches products, handles purchases, and monitors transactions. Here's a production-ready implementation using the @Observable macro:
import StoreKit
import Observation
@Observable
final class StoreManager {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs: Set<String> = []
private(set) var coinBalance: Int = 0
private let productIDs: Set<String> = [
"com.app.coins.100",
"com.app.premium.unlock",
"com.app.pro.monthly",
"com.app.pro.yearly"
]
private var transactionListener: Task<Void, Never>?
init() {
transactionListener = listenForTransactions()
Task { await loadProducts() }
}
deinit {
transactionListener?.cancel()
}
func loadProducts() 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 -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updatePurchasedState(transaction)
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
return nil
@unknown default:
return nil
}
}
func restorePurchases() async {
try? await AppStore.sync()
}
private func checkVerified<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let value):
return value
}
}
private func listenForTransactions() -> Task<Void, Never> {
Task.detached {
for await result in Transaction.updates {
if let transaction = try? self.checkVerified(result) {
await self.updatePurchasedState(transaction)
await transaction.finish()
}
}
}
}
@MainActor
private func updatePurchasedState(
_ transaction: Transaction
) {
switch transaction.productType {
case .consumable:
coinBalance += 100
case .nonConsumable, .autoRenewable:
if transaction.revocationDate == nil {
purchasedProductIDs.insert(transaction.productID)
} else {
purchasedProductIDs.remove(transaction.productID)
}
default:
break
}
}
}
A few things worth calling out here:
- The
listenForTransactions()method runs for the entire app lifetime, catching renewals, refunds, and purchases made on other devices. Don't skip this — it's more important than it looks. - We always call
transaction.finish()after unlocking the content, never before. If you finish first and then crash before granting access, the user loses their purchase. I've seen this bug in production apps, and it's not fun to debug. - The
checkVerifiedhelper unwraps StoreKit's cryptographic verification. If a transaction can't be verified, we throw rather than granting access.
Loading Entitlements on App Launch
When the app launches, you need to check which products the user has already purchased. StoreKit 2 makes this refreshingly simple with Transaction.currentEntitlements:
extension StoreManager {
func loadEntitlements() async {
for await result in Transaction.currentEntitlements {
if let transaction = try? checkVerified(result) {
await updatePurchasedState(transaction)
}
}
}
}
Call this method early in your app lifecycle — for example, in a .task modifier on your root view.
The currentEntitlements sequence always contains the latest list of active non-consumable purchases and subscriptions, even if they were purchased on another device. This basically eliminates the need for a separate "Restore Purchases" flow for entitlement checks. That said, you should still offer a restore button — App Store Review expects it.
SwiftUI Merchandising Views
This is where StoreKit 2 really shines. The suite of built-in SwiftUI views handles product display, pricing, and purchase flow out of the box. These views respect the user's locale, format prices correctly, and manage loading states automatically. No more hand-rolling paywall UI.
ProductView: Displaying a Single Product
The simplest way to show a purchasable product is with ProductView. Pass it a product ID, and it handles everything:
import StoreKit
struct PremiumUnlockView: View {
var body: some View {
ProductView(id: "com.app.premium.unlock") {
PremiumIcon()
}
.productViewStyle(.large)
.padding()
}
}
ProductView comes in three styles: .compact, .regular, and .large. Each adjusts the layout to show the product name, description, and price with appropriate prominence. The trailing closure lets you supply a custom icon that replaces the default promotional image.
StoreView: Showing Multiple Products
When you need to display several products in a scrollable list, StoreView is what you want:
struct ShopView: View {
let productIDs = [
"com.app.coins.100",
"com.app.premium.unlock"
]
var body: some View {
StoreView(ids: productIDs) { product in
ProductIcon(productID: product.id)
}
.productViewStyle(.compact)
.storeButton(.visible, for: .restorePurchases)
}
}
StoreView automatically handles a restore purchases button — you control its visibility with the .storeButton modifier. It also adapts its layout based on available space, which is a nice touch.
SubscriptionStoreView: Full Subscription Paywall
For auto-renewable subscriptions, SubscriptionStoreView creates a complete paywall with plan comparison, pricing, and purchase buttons — all from a single view. Seriously, this used to take hundreds of lines of custom code:
struct PaywallView: View {
var body: some View {
SubscriptionStoreView(groupID: "598392E1") {
VStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.system(size: 50))
.foregroundStyle(.yellow)
Text("Unlock Pro Access")
.font(.title.bold())
Text("Get unlimited features, priority support, and early access to new content.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
.subscriptionStoreControlStyle(.prominentPicker)
.subscriptionStoreButtonLabel(.multiline)
.storeButton(.visible, for: .restorePurchases)
}
}
The view in the trailing closure becomes your marketing header — use it to highlight key benefits. The .subscriptionStoreControlStyle modifier switches between .picker (segmented control), .prominentPicker (larger cards), and .buttons (individual CTA buttons). The SubscriptionStoreView also automatically checks for existing subscriber status and introductory offer eligibility, which saves you a ton of work.
SubscriptionOfferView: New in iOS 26
Introduced at WWDC 2025, SubscriptionOfferView is designed for merchandising individual subscription plans. Unlike SubscriptionStoreView which shows an entire subscription group, this one focuses on a single subscription and highlights its offers:
struct PromoView: View {
var body: some View {
SubscriptionOfferView(
productID: "com.app.pro.yearly"
)
}
}
This view is especially useful for upgrade prompts, onboarding flows, and contextual upsells where you want to spotlight one specific plan rather than presenting the full paywall. I think we'll see a lot of apps adopting this for targeted conversion flows.
Handling Purchases Manually
While the SwiftUI merchandising views handle purchases internally, sometimes you need finer control — maybe you want to track analytics events or build a completely custom UI. Here's how to handle the purchase result from your StoreManager:
struct CustomPurchaseButton: View {
let product: Product
@Environment(StoreManager.self) private var store
@State private var isPurchasing = false
@State private var errorMessage: String?
var body: some View {
Button {
Task {
isPurchasing = true
defer { isPurchasing = false }
do {
if let _ = try await store.purchase(product) {
// Purchase succeeded — UI updates
// via @Observable automatically
}
} catch Product.PurchaseError.productUnavailable {
errorMessage = "This product is currently unavailable."
} catch {
errorMessage = error.localizedDescription
}
}
} label: {
HStack {
Text(product.displayName)
Spacer()
Text(product.displayPrice)
.bold()
}
}
.disabled(isPurchasing)
.alert("Purchase Error",
isPresented: .constant(errorMessage != nil)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
}
}
Always use product.displayPrice rather than formatting the price yourself. This property respects the user's locale and currency, handling edge cases like Japanese Yen (no decimal places) or Swiss Franc formatting automatically. Trust me, you don't want to deal with currency formatting manually.
Subscription Status and Renewal Info
For subscription-based apps, you need to know the user's current subscription status to gate content properly. StoreKit 2 gives you Product.SubscriptionInfo.Status for this:
extension StoreManager {
func checkSubscriptionStatus() async -> Bool {
guard let statuses = try? await Product.SubscriptionInfo
.status(for: "598392E1") else {
return false
}
for status in statuses {
guard let transaction = try? checkVerified(
status.transaction
) else { continue }
if status.state == .subscribed ||
status.state == .inGracePeriod {
return true
}
}
return false
}
func getRenewalInfo() async -> Product.SubscriptionInfo.RenewalInfo? {
guard let statuses = try? await Product.SubscriptionInfo
.status(for: "598392E1") else {
return nil
}
for status in statuses {
if let renewalInfo = try? checkVerified(
status.renewalInfo
) {
return renewalInfo
}
}
return nil
}
}
The RenewalInfo type tells you whether the subscription will auto-renew, when the next renewal date is, and — for expired subscriptions — the expiration reason. Use this data to show appropriate UI: a "Your subscription renews on..." message for active subscribers, or a "Resubscribe" prompt for lapsed users.
Win-Back Offers in iOS 26
Win-back offers are a powerful addition from WWDC 2025 for re-engaging subscribers who've cancelled. Unlike promotional offers that you trigger manually, win-back offers are delivered through the App Store automatically when a customer is eligible.
Your app receives win-back offer messages through the existing Transaction.updates listener. The App Store presents the offer sheet to the user, and if they accept, you handle the resulting transaction just like any other purchase. The key here is that your transaction observer must be active — which it already is if you followed the StoreManager pattern above.
You configure win-back offers in App Store Connect by specifying the discount, duration, and eligibility criteria. No additional client-side code is needed beyond the transaction listener you already have in place. That's it. Sometimes the best feature is the one that requires zero extra code.
The AppTransaction and Customer Attribution
Starting with iOS 18.4 and expanded in iOS 26, AppTransaction includes the appTransactionID field — a globally unique identifier for each Apple Account that downloads your app. This is significant because it persists across reinstalls and works even for Family Sharing members, where each family member gets their own unique ID:
func getAppTransactionInfo() async {
guard let result = try? await AppTransaction.shared,
let appTransaction = try? checkVerified(result) else {
return
}
let transactionID = appTransaction.appTransactionID
let originalPlatform = appTransaction.originalPlatform
// Send to your analytics backend
print("Customer ID: \(transactionID)")
print("First downloaded on: \(originalPlatform)")
}
The originalPlatform field reveals where the user first downloaded your app (iOS, macOS, tvOS, or visionOS), which is surprisingly useful for understanding cross-platform usage patterns.
Testing In-App Purchases
StoreKit 2 testing in Xcode 26 is substantially better than what came before. Let's go through the key testing strategies.
Local Testing with StoreKit Configuration
With your StoreKit configuration file enabled in your scheme, all purchases happen locally without contacting the App Store. You can simulate purchases, refunds, subscription renewals, and even network interruptions.
Open the StoreKit Transaction Manager from Xcode's Debug menu while your app is running to view all transactions, trigger refunds, and manipulate subscription states. It's incredibly handy for edge-case testing.
Writing Automated Tests
StoreKit provides a testing framework that lets you write automated tests for your purchase flows:
import Testing
import StoreKitTest
@Test func purchaseFlowCompletesSuccessfully() async throws {
let session = try SKTestSession(
configurationFileNamed: "Products"
)
session.disableDialogs = true
session.clearTransactions()
let store = StoreManager()
await store.loadProducts()
let product = store.products.first {
$0.id == "com.app.premium.unlock"
}!
let transaction = try await store.purchase(product)
#expect(transaction != nil)
#expect(store.purchasedProductIDs.contains(product.id))
}
Sandbox Testing on Device
For end-to-end testing closer to production, use the sandbox environment with a dedicated sandbox Apple ID. Go to Settings → App Store → Sandbox Account on your device to sign in with your test account.
One thing to keep in mind: sandbox subscription renewals happen on an accelerated schedule — a monthly subscription renews every five minutes. It can feel chaotic, but it's great for quickly validating your renewal handling logic.
Server-Side Verification
While StoreKit 2 performs on-device cryptographic verification automatically, production apps with server-side entitlement management should also validate transactions on the backend. Here's the recommended approach using the App Store Server API:
- Your app sends the signed transaction data (
JWSRepresentation) to your server. - Your server verifies the JWS signature using Apple's public certificate chain — no need to call Apple's servers for basic validation.
- For more detailed status checks, your server authenticates with a JWT and queries the App Store Server API endpoints.
- Your server grants or revokes access based on the verification result.
Apple provides the App Store Server Library in Java, Python, Node.js, and Swift to simplify JWS creation and verification. If you're still using the deprecated verifyReceipt endpoint, migrating to the Server API should be a priority — receipt validation is no longer the recommended path.
Best Practices for Production
After working with StoreKit 2 across multiple projects, these are the practices that matter most:
- Never hardcode prices. Always use
product.displayPrice. Prices vary by region and can change without an app update. - Finish transactions only after granting access. If your app crashes between finishing a transaction and unlocking content, the user loses their purchase with no recourse. This is the single most common StoreKit bug I see in production apps.
- Start the transaction listener immediately. Initialize it in your app's entry point, before any views appear. Delayed listeners miss transactions that arrive during launch.
- Handle the grace period state. When a subscription enters
.inGracePeriod, the user should retain access while Apple retries billing. Cutting them off prematurely leads to negative reviews. - Always provide restore purchases. Even though
currentEntitlementshandles most cases, App Store Review expects a visible restore button. Don't skip it. - Use
appAccountTokenfor user mapping. When initiating a purchase, pass a UUID that maps to your app's user account. This links App Store transactions to your backend user records. - Avoid StoreKit 1 entirely. It was formally deprecated at WWDC 2024. All new development should use StoreKit 2 exclusively — there's no reason to look back.
Putting It All Together
So, let's bring everything together. Here's how you wire the full setup into your SwiftUI app:
import SwiftUI
@main
struct MyApp: App {
@State private var store = StoreManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
.task {
await store.loadEntitlements()
}
}
}
}
struct ContentView: View {
@Environment(StoreManager.self) private var store
var body: some View {
NavigationStack {
List {
if !store.purchasedProductIDs.contains(
"com.app.premium.unlock"
) {
Section("Upgrade") {
NavigationLink("Go Premium") {
PaywallView()
}
}
}
Section("Your Account") {
Label(
"\(store.coinBalance) Coins",
systemImage: "dollarsign.circle"
)
}
Section("Shop") {
NavigationLink("Buy Coins") {
ShopView()
}
}
}
.navigationTitle("My App")
}
}
}
This architecture keeps your store logic centralized in StoreManager, uses SwiftUI's environment for dependency injection, and loads entitlements eagerly so the UI reflects the correct state from the first frame. Clean, maintainable, and production-ready.
Frequently Asked Questions
Do I still need to use receipts with StoreKit 2?
Nope. StoreKit 2 replaces receipt-based validation entirely. Transactions are cryptographically signed in JWS format, and you can verify them both on-device (automatically by StoreKit) and on your server using Apple's public certificate chain. The legacy verifyReceipt endpoint shouldn't be used for new development.
Can I use StoreKit 2 while still supporting iOS 15?
Yes! The core APIs — Product, Transaction, Transaction.updates, and Transaction.currentEntitlements — all work on iOS 15+. The SwiftUI merchandising views (ProductView, StoreView, SubscriptionStoreView) require iOS 17+, and SubscriptionOfferView requires iOS 26.
How do I test subscription renewal locally?
Use a StoreKit configuration file in Xcode and enable it in your scheme. Subscriptions renew on an accelerated schedule in the local environment. You can also use the StoreKit Transaction Manager in the Debug menu to manually trigger renewals, cancellations, and refunds while your app is running.
What's the difference between StoreView and SubscriptionStoreView?
StoreView displays any collection of products (consumables, non-consumables, or subscriptions) in a list format. SubscriptionStoreView is specifically designed for auto-renewable subscription groups — it handles plan comparisons, introductory offer eligibility, upgrade/downgrade management, and provides a complete paywall experience tailored to the subscription lifecycle.
How do win-back offers work in iOS 26?
Win-back offers are configured in App Store Connect and delivered to eligible churned subscribers through the App Store. Your app doesn't need to trigger them manually. When a user is eligible, the App Store presents the offer automatically. If the user accepts, the transaction flows through your existing Transaction.updates listener. The only requirement is that your transaction observer is active when the app launches — which it should be if you followed the pattern in this guide.