NewThe Flutter Kit — Flutter boilerplate$149$69
Tutorial

Complete StoreKit 2 in SwiftUI 2026: Every API, Working Code

The deep 2026 guide to StoreKit 2 in SwiftUI. Every API you will use, the full subscription implementation, JWS validation on a server, App Store Server API, StoreKit configuration files, and when to graduate to RevenueCat.

Ahmed GaganAhmed Gagan
20 min read

Skip 100+ hours of setup. Get The Swift Kit $149 $99 one-time

Get it now →

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.

DimensionStoreKit 1StoreKit 2
API styleDelegate plus completion handlersasync/await, AsyncSequence
Receipt formatPKCS7 receipt blobJWS signed JSON
Server validationverifyReceipt endpointApp Store Server API plus JWS verify
Minimum iOSiOS 3iOS 15
Status in 2026DeprecatedActive, recommended
TestingSandbox onlyStoreKit Configuration plus Sandbox

The Four APIs You Will Actually Use

Roughly 90 percent of subscription flows live in these four APIs:

  1. Product.products(for: ids) loads product metadata.
  2. product.purchase() initiates the purchase and returns a Transaction.
  3. Transaction.currentEntitlements provides the user active entitlements as a stream.
  4. Transaction.updates is 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.storekit

The 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 call Transaction.updates as 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.updates like any other. If you do not want them entitled, check transaction.ownershipType == .purchased instead of including .familyShared.

When to Graduate from StoreKit 2 to RevenueCat

Three signals it is time:

  1. You find yourself building cross device subscription sync. RevenueCat handles this automatically with no extra code.
  2. Your team needs an analytics dashboard. MRR, churn, cohort retention beyond what App Store Connect provides.
  3. 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

Share this article
Limited-time · price rises to $149 soon

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and a full design system. Stop rebuilding boilerplate — start building your product.

$149$99one-time · save $50
  • Full source code
  • Unlimited projects
  • Lifetime updates
  • 50+ makers shipping