Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 066 / 100

In-app purchases with StoreKit 2: lessons from production

Sell subscriptions and features.

Expona Admin7 min read

In-app purchases with StoreKit 2: lessons from production

We've shipped subscriptions and premium features across multiple production apps, and StoreKit 2 has fundamentally changed how we handle monetization on iOS. The framework cuts through the complexity of StoreKit 1 with async/await, automatic transaction handling, and built-in verification—but getting it right requires understanding the flow from App Store Connect configuration through entitlement checks and testing.

This guide walks through what we've learned deploying in-app purchases with StoreKit 2 in real applications. We'll cover product configuration, the purchase flow with transaction verification, checking entitlements, and testing patterns that catch issues before production.

Products: Configure in App Store Connect and load with Product.products(for:)

Everything starts in App Store Connect. You define your products there—subscriptions, one-time purchases, consumables—and StoreKit 2 reads that configuration. The product IDs you set up in App Store Connect become the strings you use in your code to fetch and purchase.

In production, we typically organize product IDs as constants to avoid typos:

swift
struct ProductIDs {
    static let premiumMonthly = "com.myapp.premium.monthly"
    static let premiumAnnual = "com.myapp.premium.annual"
    static let advancedFeature = "com.myapp.feature.advanced"
}

Then, load products from the App Store using Product.products(for:):

swift
@MainActor
class StoreManager: ObservableObject {
    @Published var products: [Product] = []
    @Published var isLoading = true
    @Published var error: String?
    
    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }
        
        let productIDs = [
            ProductIDs.premiumMonthly,
            ProductIDs.premiumAnnual,
            ProductIDs.advancedFeature
        ]
        
        do {
            self.products = try await Product.products(for: productIDs)
        } catch {
            self.error = "Failed to load products: \(error.localizedDescription)"
        }
    }
}

Call this early—typically in your app delegate or root view's .onAppear. The API returns products available for purchase in the user's region and currency. Notice we use @MainActor because the @Published updates must happen on the main thread.

In production, we cache products because App Store responses are stable; fetching every time users open the app wastes API quota and feels sluggish. Store them in a database or UserDefaults if you need to survive app restarts.

Purchase: async purchase with transaction verification

StoreKit 2's purchase flow is cleaner than its predecessor. Call purchase() on a product, await the result, and get back a VerificationResult wrapping a Transaction. This automatic verification is crucial—the framework cryptographically validates the transaction server-side, so you don't have to replay receipts.

swift
func purchaseProduct(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase()
    
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await transaction.finish()
        return transaction
        
    case .userCancelled:
        print("User cancelled the purchase")
        return nil
        
    case .pending:
        print("Purchase is pending, waiting for user action")
        return nil
        
    @unknown default:
        throw PurchaseError.unknownResult
    }
}

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

After a successful purchase, call transaction.finish(). This tells the App Store you've delivered the content and allows the user to repurchase (for one-time purchases) or renew (for subscriptions). Don't skip this—unfinished transactions stay in limbo and confuse your entitlement checks.

In production, we wrapping purchase calls in a loading state UI because network latency can be noticeable:

swift
@MainActor
class StoreManager: ObservableObject {
    @Published var isPurchasing = false
    
    func purchase(_ product: Product) async {
        isPurchasing = true
        defer { isPurchasing = false }
        
        do {
            let transaction = try await purchaseProduct(product)
            if transaction != nil {
                print("Purchase successful")
                // Trigger UI refresh for entitlements
            }
        } catch {
            print("Purchase failed: \(error)")
        }
    }
}

Entitlements: check Transaction.currentEntitlements

After purchase, you need to know if a user is entitled to a feature. StoreKit 2 provides Transaction.currentEntitlements—an async sequence of active transactions. This includes subscriptions, non-consumables, and finished one-time purchases.

swift
@MainActor
class EntitlementManager: ObservableObject {
    @Published var isPremium = false
    @Published var entitlements: Set = []
    
    func checkEntitlements() async {
        var entitlementSet: Set = []
        
        for await result in Transaction.currentEntitlements {
            if let transaction = try? checkVerified(result) {
                entitlementSet.insert(transaction.productID)
            }
        }
        
        self.entitlements = entitlementSet
        self.isPremium = entitlementSet.contains(ProductIDs.premiumMonthly) ||
                         entitlementSet.contains(ProductIDs.premiumAnnual)
    }
    
    private func checkVerified(_ verification: VerificationResult) throws -> T {
        switch verification {
        case .unverified:
            throw EntitlementError.verificationFailed
        case .verified(let verified):
            return verified
        }
    }
}

Call this on app launch and whenever a transaction completes. We store the set of active product IDs and use simple membership checks throughout the app:

swift
if entitlementManager.isPremium {
    // Show premium features
} else {
    // Show paywall
}

For subscriptions, currentEntitlements automatically reflects renewal status. If a subscription expires or is cancelled by the user, it disappears from the sequence. This means your entitlement state is always accurate without manual expiry checks.

Test: StoreKit configuration files in Xcode

Testing in-app purchases without StoreKit configuration files is painful—you hit the sandbox, deal with rate limits, and can't easily test edge cases. Instead, use StoreKit configuration files, which run entirely locally in the simulator.

Create a new file in Xcode: File → New → StoreKit Configuration File. Define your test products and subscriptions:

swift
{
  "version": 1,
  "products": [
    {
      "displayPrice": "9.99",
      "familyShareable": false,
      "internalID": "com.myapp.premium.monthly",
      "localizations": [
        {
          "description": "Monthly premium access",
          "displayName": "Premium Monthly",
          "locale": "en_US"
        }
      ],
      "price": 9.99,
      "productID": "com.myapp.premium.monthly",
      "productType": "AutoRenewableSubscription",
      "subscriptionGroupID": "group.premium",
      "subscriptionPeriod": "P1M"
    }
  ],
  "subscriptionGroups": [
    {
      "id": "group.premium",
      "localizations": [],
      "subscriptions": [
        "com.myapp.premium.monthly",
        "com.myapp.premium.annual"
      ]
    }
  ]
}

Then, in your scheme, link this file under the Run settings. In Xcode, select your scheme → Edit Scheme → Run → Arguments → StoreKit Configuration, and pick your file.

In the simulator, you can now:

  • Purchase products without using real payment methods
  • Simulate subscription renewal and expiry by editing the configuration or using the StoreKit debugger
  • Test in multiple regions (the framework respects your simulator's locale settings)
  • Verify your purchase and entitlement flows without network latency

We use configuration files for all development and CI testing. Occasionally we test in the sandbox, but only after local validation passes.

Common mistakes

Forgetting to call transaction.finish(): Unfinished transactions block repurchases and confuse entitlement checks. Always finish in a defer or after handling.

Ignoring verification results: StoreKit 2 wraps transactions in VerificationResult. Don't unwrap them without checking—unverified transactions are fraud risks. Always switch on the result and handle both cases.

Checking entitlements once at launch: Subscriptions renew, cancellations propagate, and test users change. Listen to transaction updates with Transaction.updates and refresh entitlements when they fire:

swift
func listenForTransactionUpdates() {
    Task {
        for await _ in Transaction.updates {
            await checkEntitlements()
        }
    }
}

Loading products repeatedly: Cache product data—fetching from the App Store on every view load wastes quota and slows the UI. Fetch once on app launch, or implement a simple TTL-based cache.

Not testing subscription renewal: Subscription logic is hard to test manually because renewals happen daily or annually. Use StoreKit configuration files and the simulator's time manipulation to simulate renewal scenarios before shipping.

Key takeaways

  • Define product IDs as constants in App Store Connect, then fetch them with Product.products(for:) and cache aggressively.
  • Handle purchase results with VerificationResult—never unwrap unverified transactions—and always call finish() after handling.
  • Check entitlements with Transaction.currentEntitlements on app launch and whenever Transaction.updates fires.
  • Use StoreKit configuration files for local testing; they're faster, cheaper, and let you simulate edge cases without the sandbox.
  • Listen to transaction updates to stay synchronized with subscription changes and cancellations.
  • Wrap purchase flows in loading states; network latency is real and users expect feedback.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles