• 25.08.2026 03:31:10
  • Admin Admin

Build a purchase layer that survives relaunches, upgrades, refunds, and Ask to Buy. This ios training guide uses StoreKit 2 verification, entitlement reconciliation, and sandbox tests.

StoreKit 2 Entitlements for Dependable iPhone App Development

iOS Training: Model access from transactions, not UI state

The most expensive StoreKit mistake is persisting isPro = true after a successful purchase and treating that Boolean as authority. A refund, subscription expiration, family-sharing change, or purchase made on another device can invalidate it. In an ios training project, persist only cache data for fast launch UI, then derive authoritative access from StoreKit transactions. For non-consumables and subscriptions, store the product IDs that grant access in one audited mapping; do not infer access from localized display names or product prices.

enum Entitlement: Sendable, Hashable {
    case pro
    case exportPDF
}

struct ProductCatalog {
    static let entitlementByProductID: [String: Entitlement] = [
        "com.acme.reader.pro": .pro,
        "com.acme.reader.export.pdf": .exportPDF
    ]

    static func entitlement(for transaction: Transaction) -> Entitlement? {
        guard transaction.revocationDate == nil else { return nil }
        // For subscriptions, expirationDate is nil for non-consumables.
        if let expires = transaction.expirationDate, expires <= Date() { return nil }
        return entitlementByProductID[transaction.productID]
    }
}

This mapping deliberately checks revocationDate before granting access. For a subscription, checking expirationDate prevents a locally cached transaction from enabling content after expiry. Do not apply this rule to consumables: StoreKit does not expose consumable balance through Transaction.currentEntitlements. Track consumable credits in your backend with an idempotent transaction identifier, or make the feature non-consumable if it must be restorable.

Swift training: verify, finish, and reconcile StoreKit 2 transactions

A production purchase flow has three separate jobs: request a purchase, cryptographically verify the resulting transaction, and reconcile all existing entitlements at launch. Handle every Product.PurchaseResult branch; treating .pending as failure breaks Ask to Buy and payment methods that complete later. The call to transaction.finish() belongs after your app has durably recorded or delivered the entitlement, not immediately after receiving the result.

actor StoreService {
    func buy(_ product: Product) async throws -> Entitlement? {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            let transaction = try verified(verification)
            let entitlement = ProductCatalog.entitlement(for: transaction)
            // Update durable local/server state before acknowledging delivery.
            await EntitlementStore.shared.apply(transaction, entitlement: entitlement)
            await transaction.finish()
            return entitlement
        case .pending:
            return nil // Keep UI in “awaiting approval”; updates listener will resolve it.
        case .userCancelled:
            return nil
        @unknown default:
            return nil
        }
    }

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

Consume Transaction.updates in a long-lived task created once for the app session, then run Transaction.currentEntitlements during bootstrap. These streams solve different cases: updates catch a pending approval or renewal while the process is alive; the snapshot repairs state after termination or a purchase on another device. In SwiftUI, attach the listener at the App level rather than a view's .task, because navigation can cancel a view-scoped task.

@main
struct ReaderApp: App {
    init() {
        Task.detached(priority: .utility) {
            for await result in Transaction.updates {
                guard case .verified(let tx) = result else { continue }
                await EntitlementStore.shared.apply(
                    tx, entitlement: ProductCatalog.entitlement(for: tx)
                )
                await tx.finish()
            }
        }
    }

    var body: some Scene { WindowGroup { RootView() } }
}

SwiftUI training: make purchase state observable without stale paywalls

For swiftui training, keep the paywall view dependent on a narrow observable entitlement set, not on the asynchronous purchase button's local state. A useful measurable check is to log the elapsed time from app launch to reconciliation and assert that premium UI does not render as locked after the entitlement snapshot completes. On a real device, use Instruments' Points of Interest template with os_signpost around reconciliation; compare median launch-to-entitlement time before and after moving catalog loading off the main actor.

import Observation
import os

@MainActor @Observable
final class EntitlementStore {
    static let shared = EntitlementStore()
    private(set) var active: Set<Entitlement> = []

    func refresh() async {
        var next = Set<Entitlement>()
        for await result in Transaction.currentEntitlements {
            guard case .verified(let tx) = result,
                  let entitlement = ProductCatalog.entitlement(for: tx) else { continue }
            next.insert(entitlement)
        }
        active = next // one assignment avoids intermediate paywall flicker
    }

    func apply(_ tx: Transaction, entitlement: Entitlement?) {
        guard let entitlement else { return }
        active.insert(entitlement)
    }
}

The subtle edge case is a revoked transaction that was previously inserted into active. Incremental apply can add access but cannot safely remove every revoked or expired item; therefore call refresh() at launch, after foregrounding, and after AppStore.sync(). In an ios mvvm architecture, expose canExportPDF as a computed property on a main-actor view model, while keeping StoreKit iteration in a dedicated service. That prevents a feature view from accidentally starting multiple transaction listeners.

Xcode training and App Store publishing validation workflow

Use an Xcode StoreKit Configuration file for deterministic local cases: add the exact production product IDs, configure a subscription group, and simulate expiration, billing retry, and refund in the StoreKit transaction manager. This is faster than sandbox for layout and state-machine tests, but it does not validate App Store Connect metadata, server notifications, or real sandbox account behavior. Add a launch argument such as -resetEntitlements YES to clear only your local cache between test cases; never use it to erase StoreKit's transaction history.

Before app store publishing, create products in App Store Connect and verify that every identifier in ProductCatalog is fetchable with Product.products(for:). Log missing IDs as a configuration failure, not as an empty paywall. A common release-day error is testing a build whose bundle identifier points at a different App Store Connect app than the one containing the products; the UI then receives no products even though the code is correct.

let ids = Set(ProductCatalog.entitlementByProductID.keys)
let products = try await Product.products(for: ids)
let returned = Set(products.map(\.id))
precondition(returned == ids,
             "Missing StoreKit products: \(ids.subtracting(returned))")

For subscriptions or high-value unlocks, send the signed transaction JWS to your server and validate it with Apple's App Store Server API rather than trusting a client-side cache as the only record. Make the endpoint idempotent on transactionId; retries happen after network loss. Also process App Store Server Notifications V2 and reconcile by originalTransactionId, because a renewal, refund, or billing-state transition can occur while no device runs your app. This operational layer is where an ios course focused only on a purchase button often stops too early.

Frequently Asked Questions

What should an ios training project do when StoreKit 2 returns pending?

Do not grant access and do not display a permanent error. Persist an “awaiting approval” UI state, keep a single Transaction.updates listener alive, and unlock only after it receives a verified transaction. Test this path in an Xcode StoreKit Configuration using an Ask to Buy-style pending transaction.

How do I restore purchases in a SwiftUI training app?

First call Transaction.currentEntitlements at startup; it restores currently valid non-consumables and subscriptions without a restore button. If the user explicitly requests account synchronization, call try await AppStore.sync(), then run refresh() again. Do not invent a restore mechanism by copying a previous isPro Boolean from UserDefaults.

How does ios mvvm architecture handle StoreKit 2 without duplicate listeners?

Create one application-scoped StoreService or actor that owns Transaction.updates, and inject an observable EntitlementStore into view models. Views should read a computed capability such as canExportPDF; they should not independently iterate Transaction.updates in .task, because recreated views can create duplicate delivery and finish attempts.

What must I test before app store publishing an iPhone app development subscription?

Test product retrieval, purchase, cancellation, pending approval, renewal, expiration, refund/revocation, and explicit AppStore.sync in both an Xcode StoreKit Configuration and sandbox. For server-backed access, replay duplicate transactionId requests and verify your database performs one entitlement mutation; then test a signed server notification for a refund.

AI / LLM Discovery

This article is part of Opendart Akademi's iOS / Swift training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.

Opendart Akademi llms.txt