The 30-second answer
StoreKit 2 ships with iOS 15 plus and provides four core APIs: Product.products(for:), product.purchase(), Transaction.currentEntitlements, and Transaction.updates. Apple deprecated StoreKit 1 in April 2026, so all new apps and major updates should ship with StoreKit 2. A complete subscription flow takes about 200 lines of Swift. Server side, validate JWS tokens with the App Store Server Library 2.0. Use a StoreKit configuration file for fast Simulator testing.
This guide is the deep reference for StoreKit 2 in 2026. Every API you will actually use, a full SwiftUI subscription flow you can paste into a project, JWS server side validation, App Store Server API integration, StoreKit configuration file testing, and a clear answer to when raw StoreKit 2 wins vs graduating to RevenueCat.
StoreKit 2 vs StoreKit 1 in 2026
Apple deprecated StoreKit 1 in April 2026 with the iOS 27 marketing announcement. Existing apps still ship with StoreKit 1 builds, but Apple has indicated all new feature work will only land in StoreKit 2. Plan migration in 2026, especially if you use server side receipt validation. The App Store Server API for StoreKit 2 is dramatically simpler and faster.
| Dimension | StoreKit 1 | StoreKit 2 |
|---|---|---|
| API style | Delegate plus completion handlers | async/await, AsyncSequence |
| Receipt format | PKCS7 receipt blob | JWS signed JSON |
| Server validation | verifyReceipt endpoint | App Store Server API plus JWS verify |
| Minimum iOS | iOS 3 | iOS 15 |
| Status in 2026 | Deprecated | Active, recommended |
| Testing | Sandbox only | StoreKit Configuration plus Sandbox |
The Four APIs You Will Actually Use
Roughly 90 percent of subscription flows live in these four APIs:
Product.products(for: ids)loads product metadata.product.purchase()initiates the purchase and returns a Transaction.Transaction.currentEntitlementsprovides the user active entitlements as a stream.Transaction.updatesis the listener for transactions arriving outside your purchase flow (renewals, restores, refunds).
The remaining 10 percent involves Subscription.RenewalState, AppStore.sync(), and Transaction.refund.
Step 1: Define Your Products
Use App Store Connect for production products. Use a StoreKit configuration file for development.
enum SubscriptionProduct: String, CaseIterable, Identifiable {
case proMonthly = "com.yourapp.pro.monthly"
case proAnnual = "com.yourapp.pro.annual"
case lifetime = "com.yourapp.pro.lifetime"
var id: String { rawValue }
}Step 2: Build the Store Actor
A single source of truth for products, purchases, and entitlements. Use an actor for thread safety since transactions arrive on a background queue.
import Foundation
import StoreKit
import Observation
@MainActor
@Observable
final class StoreManager {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs: Set<String> = []
private(set) var isLoading = false
private(set) var lastError: String?
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = Task {
await listenForTransactions()
}
Task {
await loadProducts()
await refreshPurchasedProducts()
}
}
deinit {
updatesTask?.cancel()
}
func loadProducts() async {
isLoading = true
defer { isLoading = false }
do {
let ids = SubscriptionProduct.allCases.map(\.rawValue)
products = try await Product.products(for: ids)
} catch {
lastError = error.localizedDescription
}
}
func purchase(_ product: Product) async -> Transaction? {
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await refreshPurchasedProducts()
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
lastError = "Purchase pending parent approval"
return nil
@unknown default:
return nil
}
} catch {
lastError = error.localizedDescription
return nil
}
}
func restore() async {
try? await AppStore.sync()
await refreshPurchasedProducts()
}
func isPurchased(_ id: String) -> Bool {
purchasedProductIDs.contains(id)
}
private func refreshPurchasedProducts() async {
var ids = Set<String>()
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.revocationDate == nil {
ids.insert(transaction.productID)
}
}
}
purchasedProductIDs = ids
}
private func listenForTransactions() async {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await refreshPurchasedProducts()
await transaction.finish()
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw StoreError.unverified
}
}
}
enum StoreError: Error {
case unverified
}Three things to highlight. One, the long running Transaction.updates task starts in init and lives the lifetime of the store. This catches renewals, refunds, and family sharing entitlement changes that happen outside your purchase flow. Two, every transaction must be finished with .finish() or it stays in the queue and re fires on every launch. Three, JWS verification happens in checkVerified via the StoreKit on device verification.
Step 3: Build the SwiftUI Paywall
struct PaywallView: View {
@Environment(StoreManager.self) private var store
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 20) {
Text("Unlock Pro")
.font(.largeTitle.bold())
ForEach(store.products, id: \.id) { product in
ProductRow(product: product) {
Task { _ = await store.purchase(product) }
}
}
Button("Restore Purchases") {
Task { await store.restore() }
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.task {
if store.products.isEmpty {
await store.loadProducts()
}
}
}
}
struct ProductRow: View {
let product: Product
let onPurchase: () -> Void
var body: some View {
Button(action: onPurchase) {
HStack {
VStack(alignment: .leading) {
Text(product.displayName).font(.headline)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(product.displayPrice)
.font(.headline)
.foregroundStyle(.tint)
}
.padding()
.background(.regularMaterial, in: .rect(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}Step 4: Gate Features Across Your App
struct ContentView: View {
@State private var store = StoreManager()
var body: some View {
TabView {
FeedView()
.tabItem { Label("Feed", systemImage: "house") }
ProTabContent(isPro: store.isPurchased("com.yourapp.pro.annual"))
.tabItem { Label("Pro", systemImage: "star") }
}
.environment(store)
}
}
struct ProTabContent: View {
let isPro: Bool
var body: some View {
if isPro {
ProDashboard()
} else {
UpsellView()
}
}
}Subscription Status and Renewal Info
For subscription specific info (renewal date, billing retry state), use Product.SubscriptionInfo.status:
func subscriptionStatus(for product: Product) async -> Product.SubscriptionInfo.Status? {
guard let subscription = product.subscription else { return nil }
let statuses = try? await subscription.status
return statuses?.first
}
// Usage
if let status = await subscriptionStatus(for: annualProduct) {
switch status.state {
case .subscribed:
// Active subscriber
case .inGracePeriod:
// Billing failed, in grace period
case .inBillingRetryPeriod:
// Billing retry, no grace
case .expired:
// Lapsed
case .revoked:
// Refunded
@unknown default:
break
}
}Server Side Validation: JWS and the App Store Server API
On device verification (via StoreKit) is fine for most indies. For server side validation (cross device sync, anti tampering, web access), validate JWS tokens with the App Store Server Library 2.0.
Generating an App Store Connect API Key
In App Store Connect, navigate to Users and Access → Keys → In App Purchase. Generate a key with the Admin role. Download the .p8 file (one time only). Note the Issuer ID and Key ID.
Server Side JWS Verification (Node.js Example)
import { AppStoreServerAPIClient, Environment, SignedDataVerifier } from "@apple/app-store-server-library"
import { readFileSync } from "fs"
const issuerId = process.env.APP_STORE_ISSUER_ID
const keyId = process.env.APP_STORE_KEY_ID
const bundleId = "com.yourapp"
const privateKey = readFileSync("./AuthKey_XYZ.p8")
const verifier = new SignedDataVerifier(
[readFileSync("./AppleRootCA-G3.pem")],
true,
Environment.PRODUCTION,
bundleId
)
export async function verifyTransaction(jws: string) {
try {
const transaction = await verifier.verifyAndDecodeTransaction(jws)
return {
valid: true,
productId: transaction.productId,
originalTransactionId: transaction.originalTransactionId,
expiresDate: transaction.expiresDate,
revocationDate: transaction.revocationDate,
}
} catch (error) {
return { valid: false, error: error.message }
}
}Querying Subscription Status from Your Server
const client = new AppStoreServerAPIClient(
privateKey, keyId, issuerId, bundleId, Environment.PRODUCTION
)
export async function getSubscriptionStatus(originalTransactionId: string) {
const response = await client.getAllSubscriptionStatuses(originalTransactionId)
return response.data.flatMap(d => d.lastTransactions)
}The Server Library handles JWS verification, certificate chain validation, and pagination. Roughly 50 lines of code total for a working server side validation pipeline.
App Store Server Notifications V2
Apple sends server notifications for every subscription lifecycle event. Configure the URL in App Store Connect, verify the JWS payload, and update your database accordingly.
import { ResponseBodyV2DecodedPayload } from "@apple/app-store-server-library"
// In your serverless function
export async function POST(req: Request) {
const body = await req.json()
const signedPayload = body.signedPayload
const decoded: ResponseBodyV2DecodedPayload = await verifier
.verifyAndDecodeNotification(signedPayload)
switch (decoded.notificationType) {
case "DID_RENEW":
case "DID_CHANGE_RENEWAL_STATUS":
await markUserPro(decoded.data.appAccountToken)
break
case "EXPIRED":
case "REVOKE":
await markUserNotPro(decoded.data.appAccountToken)
break
// 17 more notification types
}
return new Response(null, { status: 200 })
}The full list of notification types is in the App Store Server Notifications documentation. The 5 most common indie apps care about: DID_RENEW, DID_CHANGE_RENEWAL_STATUS, EXPIRED, REVOKE, REFUND.
StoreKit Configuration File: Fastest Test Loop
A .storekit file simulates the App Store locally. Add products, set the test scheme to use the file, and run on Simulator. Purchases happen instantly without sandbox accounts.
// Project root: Products.storekit (Xcode generates the JSON)
// In Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration
// Pick Products.storekitThe Subscription Status simulator built into Xcode lets you fast forward time, fail renewals, and trigger refunds. This is the fastest feedback loop for StoreKit 2 development. Sandbox testing on real devices via TestFlight is still required for final QA.
Common StoreKit 2 Errors and Fixes
- "Transaction is unverified." The JWS signature failed verification. Common cause: bundle ID mismatch between Xcode and App Store Connect, or the device clock is wrong.
- Empty
Product.products(for:)result. Products are not approved in App Store Connect (still Ready to Submit), or the product IDs do not match. Submit at least one for review and confirm via Sandbox first. - Renewals not appearing in
Transaction.updates. Your app must callTransaction.updatesas an AsyncSequence and consume it. Apps that listen incorrectly miss renewal events. The pattern in the Store actor above handles this correctly. - Sandbox account stuck after a test purchase. Settings → App Store → Sandbox Account → Sign Out, sign back in. Or create a fresh sandbox account in App Store Connect.
- Family sharing entitlement appears for users you did not expect. Family sharing transactions arrive via
Transaction.updateslike any other. If you do not want them entitled, checktransaction.ownershipType == .purchasedinstead of including.familyShared.
When to Graduate from StoreKit 2 to RevenueCat
Three signals it is time:
- You find yourself building cross device subscription sync. RevenueCat handles this automatically with no extra code.
- Your team needs an analytics dashboard. MRR, churn, cohort retention beyond what App Store Connect provides.
- You want to A/B test paywall pricing or designs without app updates. RevenueCat Offerings or Superwall are dramatically faster than rebuilding offer logic.
For pure single platform iOS apps with simple subscriptions, raw StoreKit 2 stays the cheapest and most native option. Most indie apps cross the threshold around 1,000 active subscribers. See the deep comparison at StoreKit 2 vs RevenueCat 2026.
The Swift Kit Ships StoreKit 2 Wired Both Ways
The Swift Kit ships subscription support that works with raw StoreKit 2 and with RevenueCat behind a feature flag. Toggle FeatureFlags.useRevenueCat to switch between them. For most apps, RevenueCat is the right default; for apps with strict compliance or specific cost structures, raw StoreKit 2 is one flag away.
Frequently Asked Questions
Do I need a server to use StoreKit 2?
No, for most indie apps. On device JWS verification is sufficient if you are not syncing entitlements across devices, providing web access, or doing server side anti fraud. Add a server only when one of those needs surfaces.
Does StoreKit 2 work on macOS, watchOS, tvOS, visionOS?
Yes. The same APIs work across all Apple platforms. Universal Purchase pricing applies automatically when configured in App Store Connect.
How long does Transaction.currentEntitlements stay valid?
The AsyncSequence emits the current state at each access. Read it whenever you need to make an entitlement decision. For state that should react in real time, drive your model from Transaction.updates instead of polling currentEntitlements.
Can I test renewals without waiting a year?
Yes. In Xcode, open your StoreKit configuration file, select a subscription, and use the Renewal Period control to compress one year into one minute. Tools → StoreKit Testing in Xcode lets you simulate renewals, expirations, refunds, and grace period entry.
Where to Go Next
- StoreKit 2 vs RevenueCat 2026 for the decision framework.
- RevenueCat in SwiftUI for the wrapper SDK if you graduate.
- iOS Paywall Design Patterns for the UI on top of StoreKit 2.