Mobile Development 37 min read

Implementing In‑App Purchases with StoreKit2 in iOS SwiftUI Apps

This comprehensive guide walks through using Apple’s StoreKit2 framework to define products, fetch localized product data, display items, handle purchases, verify transactions, manage receipts, support refunds, differentiate consumables from non‑consumables, and implement auto‑renewable subscriptions in a SwiftUI iOS application.

LOFTER Tech Team
LOFTER Tech Team
LOFTER Tech Team
Implementing In‑App Purchases with StoreKit2 in iOS SwiftUI Apps

Overview

This article explains how to use Apple’s StoreKit2 framework to implement in‑app purchases (IAP) in a SwiftUI iOS app, covering product definition, StoreKit configuration files, fetching localized product information, displaying products, purchasing, receipt handling, consumables, non‑consumables, refunds, and subscriptions.

Getting Started

Create a multi‑platform Xcode project, add the StoreHelper package, enable the In‑App Purchase capability, and include StoreKit2 in the project.

Define products in Products.storekit and list their identifiers in Products.plist. Example plist snippet is shown.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Products</key>
    <array>
        <string>com.rarcher.nonconsumable.flowers-large</string>
        <string>com.rarcher.nonconsumable.flowers-small</string>
        <string>com.rarcher.nonconsumable.roses-large</string>
        <string>com.rarcher.nonconsumable.chocolates-small</string>
        <string>com.rarcher.consumable.plant-installation</string>
        <string>com.rarcher.subscription.vip.gold</string>
        <string>com.rarcher.subscription.vip.silver</string>
        <string>com.rarcher.subscription.vip.bronze</string>
    </array>
</dict>
</plist>

Fetching Products from the App Store

Call StoreHelper.requestProductsFromAppStore(productIds:) which uses Product.products(for:) to retrieve localized Product objects. The result is stored in a @Published products array.

Displaying Products

Products are shown in SwiftUI views such as PriceView with a purchase button that triggers PriceViewModel.purchase(product:).

struct PriceView: View {
    @EnvironmentObject var storeHelper: StoreHelper
    @State private var canMakePayments: Bool = false
    @Binding var purchaseState: PurchaseState
    var productId: ProductId
    var price: String
    var product: Product
    var body: some View {
        let priceViewModel = PriceViewModel(storeHelper: storeHelper, purchaseState: $purchaseState)
        HStack {
            Button(action: {
                withAnimation { purchaseState = .inProgress }
                Task.init { await priceViewModel.purchase(product: product) }
            }) {
                PriceButtonText(price: price, disabled: !canMakePayments)
            }
            .disabled(!canMakePayments)
        }
        .onAppear { canMakePayments = AppStore.canMakePayments }
    }
}

Purchasing and Transaction Verification

The purchase flow calls StoreHelper.purchase(_:), which invokes product.purchase(). StoreKit2 automatically validates the transaction using JSON Web Signature (JWS). The result is mapped to a PurchaseState enum (e.g., .purchased, .pending, .failed).

@MainActor
public func purchase(_ product: Product, options: Set<Product.PurchaseOption> = []) async throws -> (transaction: Transaction?, purchaseState: PurchaseState) {
    guard hasStarted else { return (nil, .notStarted) }
    guard AppStore.canMakePayments else { return (nil, .userCannotMakePayments) }
    guard purchaseState != .inProgress else { throw StoreException.purchaseInProgressException }
    purchaseState = .inProgress
    guard let result = try? await product.purchase(options: options) else {
        purchaseState = .failed
        throw StoreException.purchaseException
    }
    // handle result cases (success, userCancelled, pending, etc.)
    ...
}

Receipt Handling and Offline Support

StoreKit2 stores receipts in a SQLite database ( receipts.db). The app can read the receipt file path via Bundle.main.appStoreReceiptURL. For offline scenarios, purchased product IDs are persisted locally (e.g., in UserDefaults or Keychain) to act as a backup when the App Store is unavailable.

Refunds (iOS 15+)

iOS 15 introduces an in‑app refund request sheet. Use .refundRequestSheet(for:isPresented:) to present the UI and handle the result.

Consumables vs. Non‑Consumables

Consumable purchases are not recorded in the receipt; the app must track usage (e.g., via Keychain). Non‑consumable purchases can be verified with product.latestTransaction and Transaction.currentEntitlement(for:). Helper structs such as ConsumableProductId and KeychainHelper manage consumable counts.

Subscriptions

Auto‑renewable subscriptions follow the naming convention com.{developer}.subscription.{group}.{product}. The app determines the highest‑level active subscription in a group by examining product.subscription.status. Subscription info is wrapped in a SubscriptionInfo struct containing product, group, latest transaction, renewal info, and status.

public struct SubscriptionInfo: Hashable {
    public var product: Product?
    public var subscriptionGroup: String?
    public var latestVerifiedTransaction: Transaction?
    public var verifiedSubscriptionRenewalInfo: Product.SubscriptionInfo.RenewalInfo?
    public var subscriptionStatus: Product.SubscriptionInfo.Status?
}

Users can upgrade subscriptions, view details, and manage them via .manageSubscriptionsSheet(isPresented:) (iOS 15+).

Key Swift Code Samples

@available(iOS 15.0, macOS 12.0, *)
public class StoreHelper: ObservableObject {
    // Core logic for product fetching, purchasing, receipt handling, etc.
    @Published private(set) var products: [Product]?
    @Published private(set) var purchasedProducts: [ProductId] = []
    // ... other properties and methods ...
}
struct PropertyFile {
    static func read(filename: String) -> [String: AnyObject]? {
        if let path = Bundle.main.path(forResource: filename, ofType: "plist"),
           let contents = NSDictionary(contentsOfFile: path) as? [String: AnyObject] {
            return contents
        }
        return nil
    }
}
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

iOSSwiftUIConsumablesIn‑App PurchasesStoreKit2Subscriptions
LOFTER Tech Team
Written by

LOFTER Tech Team

Technical sharing and discussion from NetEase LOFTER Tech Team

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.