7.6 — StoreKit 2

Opening scenario

The CEO says: “Add a Pro tier with a monthly and annual subscription, plus a one-time ‘remove ads’ purchase, plus 100-coin and 500-coin consumables. Oh, and Family Sharing, restore-purchases, free trials, and a ‘Cancel anytime’ link.” Five years ago this required SKPaymentQueue, SKPaymentTransactionObserver, server-side receipt validation, base64-encoded payloads, and a 600-line file you copied from an Apple sample project. StoreKit 2 (iOS 15+) replaces all of it with async/await, JSON Web Signature transactions verified on-device, and a single coherent API.

ContextWhat it usually means
Reads “Product.products(for:)”Has done basic fetches
Reads “Transaction.currentEntitlements”Knows the receipt-less model
Reads “subscription status”Has built tier-aware UI
Reads “App Store Server API”Has a backend that knows about transactions
Reads “App Store Server Notifications V2”Has handled webhook lifecycle events

Concept → Why → How → Code

Concept

StoreKit 2 is built on Swift concurrency. Four types you’ll touch:

  • Product — a SKU you defined in App Store Connect, fetched async by ID.
  • Transaction — a purchase event with a cryptographic signature; verify on-device.
  • Product.SubscriptionInfo.Status — current subscription state (subscribed, in grace, expired, in billing retry).
  • AppStore.sync() — refresh the device’s transaction cache from Apple (rarely needed; iOS does this automatically).

There is no more receipt blob. Instead, each Transaction is a JWS payload your code verifies (Apple’s public key is embedded), and the API exposes the current set of valid entitlements at any moment.

Why

  • Async-native — no delegates, no queues, no observer threading bugs.
  • On-device verification — no server required to grant entitlements for basic apps.
  • App Store Server API + V2 notifications — for server-aware apps (fraud, refunds, cross-platform unlock), the server layer is also JSON-based.
  • One library, all platforms — iOS, macOS, watchOS, tvOS, visionOS, Catalyst.

How — products & purchase

import StoreKit

enum ProductID: String, CaseIterable {
    case removeAds = "com.example.app.removeAds"
    case coins100 = "com.example.app.coins100"
    case coins500 = "com.example.app.coins500"
    case proMonthly = "com.example.app.pro.monthly"
    case proYearly = "com.example.app.pro.yearly"
}

@Observable
@MainActor
final class Store {
    var products: [Product] = []
    var ownedProductIDs: Set<String> = []
    var subscriptionStatus: Product.SubscriptionInfo.Status?

    private var updateListener: Task<Void, Never>?

    init() {
        updateListener = listenForTransactions()
        Task { await loadProducts(); await refreshEntitlements() }
    }

    deinit { updateListener?.cancel() }

    func loadProducts() async {
        do {
            products = try await Product.products(for: ProductID.allCases.map(\.rawValue))
        } 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 verify(verification)
            await refreshEntitlements()
            await transaction.finish()
            return transaction
        case .userCancelled, .pending: return nil
        @unknown default: return nil
        }
    }

    private func verify<T>(_ verification: VerificationResult<T>) throws -> T {
        switch verification {
        case .unverified(_, let error): throw error
        case .verified(let value): return value
        }
    }
}

Entitlements — the modern “what is the user allowed to do”

extension Store {
    func refreshEntitlements() async {
        var owned: Set<String> = []
        for await result in Transaction.currentEntitlements {
            guard case let .verified(transaction) = result else { continue }
            owned.insert(transaction.productID)
        }
        ownedProductIDs = owned

        // Subscription status (any group)
        if let sub = products.first(where: { $0.subscription != nil })?.subscription {
            let statuses = try? await sub.status
            subscriptionStatus = statuses?.first
        }
    }
}

Transaction.currentEntitlements is the source of truth. Don’t store “user is pro” in UserDefaults and trust it; recompute from entitlements on launch and every transaction event.

Listen for store-driven transactions

extension Store {
    func listenForTransactions() -> Task<Void, Never> {
        Task.detached(priority: .background) {
            for await result in Transaction.updates {
                guard case let .verified(transaction) = result else { continue }
                await self.refreshEntitlements()
                await transaction.finish()
            }
        }
    }
}

This catches: family-sharing grants, parental purchase approvals, server-side promotional offers, refunds, billing retries — events that didn’t originate from a product.purchase() call.

Subscription status

if let status = subscriptionStatus {
    switch status.state {
    case .subscribed:
        // Active
    case .expired:
        // Past due — show paywall again
    case .inGracePeriod:
        // Payment failed but user still has access; nudge to update payment method
    case .inBillingRetryPeriod:
        // Payment failing; show recovery banner
    case .revoked:
        // Apple refunded the user; revoke access
    default: break
    }
}

status.renewalInfo (also a VerificationResult) tells you whether auto-renew is on, the next renewal product, whether the user is in a promotional offer, etc.

Restore purchases

In StoreKit 2 there’s no special “restore” call — Transaction.currentEntitlements already reflects what the user owns across devices via their Apple ID. Provide a button anyway because users expect it; it can just call await store.refreshEntitlements().

If you suspect the local cache is stale (e.g., user switched Apple IDs):

try await AppStore.sync()

This triggers a sign-in prompt; reserve for “Restore Purchases” button taps.

Promotional offers & introductory offers

Defined in App Store Connect; surfaced via:

if let subscription = product.subscription {
    if let intro = subscription.introductoryOffer {
        Text("Free trial: \(intro.period.formatted())")
    }
    for offer in subscription.promotionalOffers {
        // Signed server-side, attached at purchase time
        let signedOffer = try await server.signPromotionalOffer(productID: product.id,
                                                                 offerID: offer.id)
        try await product.purchase(options: [.promotionalOffer(offerID: offer.id, signature: signedOffer)])
    }
}

Promotional offers require server-side signing with your .p8 key; the API surface here just attaches the signed offer to the purchase call.

Server-side: App Store Server API & Notifications V2

For fraud detection, cross-platform entitlement, server-side unlock:

  • App Store Server API — REST. Given a transaction ID or original transaction ID, fetch all transactions, subscription history, refund history.
  • App Store Server Notifications V2 — Apple POSTs JWS-signed JSON to your webhook for SUBSCRIBED, DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, REFUND, CONSUMPTION_REQUEST, etc. Verify the JWS chain, update your DB, optionally push the user.

Testing

  • StoreKit Configuration File (Xcode → File → New → File → StoreKit Configuration) — local mock products. Runs in the simulator, no Apple Developer account needed for the basics.
  • Sandbox testers in App Store Connect → Users & Access → Sandbox.
  • Subscriptions in sandbox renew accelerated — 1 month becomes 5 minutes; 1 year becomes 1 hour. Plan your test sessions accordingly.

In the wild

  • Bear, Things 3, Day One — pure StoreKit 2 with one-time unlock or subscription tiers.
  • Spotify (Reader app), Netflix — do not use StoreKit for subscription signup (reader app exemption — sign up on web, log in in app).
  • Calm, Headspace — full StoreKit 2 subscriptions with intro offers, promotional offers, win-back campaigns.
  • Duolingo Super — StoreKit with aggressive yearly conversion paywall.
  • Procreate — single non-consumable IAP, the simplest StoreKit story.

Common misconceptions

  1. “I need to validate receipts on my server.” Not for entitlement gating. Transaction.currentEntitlements is signed and verified on-device. Server validation matters for fraud detection, cross-platform unlock, and revenue attribution — not for “is this user pro right now.”
  2. product.purchase() returns the transaction immediately.” It returns a PurchaseResult. .success carries a VerificationResult<Transaction> you must verify. Always also handle .userCancelled and .pending (Ask to Buy).
  3. “Subscriptions auto-renew without my app running.” They do, but you don’t see the new transaction until the user opens the app or you fetch it via the App Store Server API. Build server-side awareness for billing-cycle-driven UX.
  4. AppStore.sync() should run on every launch.” It triggers a sign-in prompt. Reserve for explicit Restore Purchases taps.
  5. “I can use StoreKit for tipping.” Tips that grant no entitlement use a special Tip Jar category of non-consumables. Selling tangible goods or external services with IAP is forbidden — use Stripe/PayPal.

Seasoned engineer’s take

StoreKit 2 is good. Annoyingly good. After a decade of receipt-validation hell, having a typed Swift API that just says “here are the user’s current entitlements” feels like cheating. Lean in:

  • Build a single Store actor/observable and inject it everywhere. Don’t sprinkle StoreKit calls across view models. One owner, one source of truth.
  • Always re-derive entitlements; never cache “is pro” as a boolean. A refund or family-sharing change must be visible within seconds of the next app launch.
  • Test the paywall on a real device with sandbox. Simulator + StoreKit Config catches the happy path; real sandbox catches the “Ask to Buy”, “billing retry”, and “tax dialog” flows.

For subscriptions specifically, the unlock UX is more important than the purchase UX. When the user’s payment fails and they enter billing retry, your app should show a calm “Your subscription is about to expire — update payment method” banner, not silently revoke access. This costs 50 lines of UI and saves thousands in churn.

TIP: If your app sells across iOS, macOS, and a web tier, use RevenueCat (covered in Chapter 11.5). It wraps StoreKit 2 + Google Play + Stripe with a single entitlement abstraction and removes the cross-platform “is this user entitled?” headache.

WARNING: Never extend access beyond what Transaction.currentEntitlements reports. “But the user paid us yesterday” doesn’t survive an App Review audit; if Apple’s source of truth says expired, your app must reflect expired. Server-side overrides for legitimate edge cases must be auditable.

Interview corner

Junior: “How do you sell a non-consumable IAP and know the user owns it?”

Define the product in App Store Connect. Load it with Product.products(for: ["productID"]). On user tap, call await product.purchase(). Verify the result, finish the transaction. On every launch, iterate Transaction.currentEntitlements and check if your product ID is present.

Mid: “How do you handle a subscription that lapses while the app is in the background?”

Subscribe to Transaction.updates from app launch in a long-lived Task. When Apple delivers a renewal event (or expiry, or refund) the iterator yields a VerificationResult<Transaction>. Verify, re-fetch Transaction.currentEntitlements, finish the transaction, update UI. For server-side awareness (e.g., to send win-back email), wire App Store Server Notifications V2 to a webhook that updates your DB and triggers the campaign.

Senior: “Design a cross-platform subscription system: iOS, web (Stripe), Android. User pays once on any platform, gets access everywhere.”

Single source of truth is your server’s “entitlements” table keyed by user ID. iOS: StoreKit 2 transaction events flow to your backend via App Store Server Notifications V2 — server creates/updates the entitlement. Stripe webhook does the same for web. Google Play Developer API + Real-Time Developer Notifications for Android. iOS client fetches entitlements from your server on launch (in addition to Transaction.currentEntitlements for offline grace), and treats server-granted entitlements as primary. Apple compliance: do not advertise web/Android purchases inside the iOS app (anti-steering) — but the unlock works once the user signs in. Sign in with Apple is convenient as the cross-platform identity layer. For UX during signup, optionally use RevenueCat to wrap all three stores behind one identifier.

Red flag: “We cache isPro = true in UserDefaults after purchase and trust it forever.”

Two failures: it doesn’t refresh after refund/family-sharing changes, and UserDefaults isn’t tamper-proof — a jailbroken device can flip the bit. Server-validated entitlement or Transaction.currentEntitlements checked at every relevant boundary is the standard.

Lab preview

Lab 7.3 — StoreKit 2 IAP builds a complete paywall: non-consumable + monthly/yearly subscription, intro offer, restore-purchases button, sandbox-tested across the full lifecycle.


Next: 7.7 — ARKit basics