Skip to content

StoreKit 2 subscriptions in Swift: a practical guide

StoreKit 2 made in-app purchases in Swift far simpler than the original API, which Apple deprecated in iOS 18. This guide walks through a subscription from App Store Connect to a working paywall, with the code we would write.

Pull House · 12 min read · Updated

Book a 30-min call

The short version

  1. Create a subscription group and its subscriptions in App Store Connect.
  2. Load them with Product.products(for:).
  3. Buy with product.purchase(), verify the result, unlock, then finish() the transaction.
  4. Work out access from Transaction.currentEntitlements, and listen to Transaction.updates from launch.
  5. Show the paywall with SubscriptionStoreView, or your own design.
  6. Test in Xcode with a StoreKit configuration file, then in the sandbox and TestFlight.

1. Set up the subscription group

Every auto-renewable subscription belongs to a subscription group, and people can hold only one subscription per group at a time, which is why Apple recommends a single group for most apps (Apple). Rank the subscriptions in the group by level, from the one that offers most (level 1) down. The levels decide what happens when people switch:

  • Upgrade: immediate, with a prorated refund of the old subscription.
  • Downgrade: takes effect at the next renewal date.
  • Crossgrade: between equal levels; immediate if the durations match, otherwise at the next renewal.

Give each subscription a clear display name, and check the subscription is available on every device type the app supports, as guideline 3.1.2(a) requires.

2. A store object, and loading products

One object owns everything StoreKit: the products, what the person is entitled to, and a listener for transactions that happen outside the app.

import StoreKit
import Observation

@MainActor
@Observable
final class Store {
    private(set) var products: [Product] = []
    private(set) var activeProductIDs: Set<String> = []
    @ObservationIgnored private var updates: Task<Void, Never>?

    init() {
        // Purchases can also happen outside the app: renewals, Ask to Buy,
        // offer codes, other devices. Listen for them from launch.
        updates = Task { [weak self] in
            for await result in Transaction.updates {
                guard case .verified(let transaction) = result else { continue }
                await self?.refreshEntitlements()
                await transaction.finish()
            }
        }
    }

    func loadProducts() async throws {
        products = try await Product.products(for: ["com.example.pro.monthly", "com.example.pro.yearly"])
            .sorted { $0.price < $1.price }
    }
}

3. Purchase and verify

StoreKit 2 signs every transaction. VerificationResult tells you whether the signature checks out; unlock only for .verified results, and call finish() once the content is delivered, or the transaction will be delivered again.

extension Store {
    func purchase(_ product: Product) async throws -> Bool {
        switch try await product.purchase() {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await refreshEntitlements()
            await transaction.finish()
            return true
        case .userCancelled, .pending:
            return false
        @unknown default:
            return false
        }
    }

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

4. Entitlements, restore and updates

Transaction.currentEntitlements returns the latest transaction for everything the person is entitled to right now, including subscriptions bought on another device or shared through Family Sharing. Skip revoked transactions, which is what a refund looks like.

extension Store {
    func refreshEntitlements() async {
        var active: Set<String> = []
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result, transaction.revocationDate == nil {
                active.insert(transaction.productID)
            }
        }
        activeProductIDs = active
    }

    var isPro: Bool { !activeProductIDs.isEmpty }

    // Only when the person taps Restore: it can ask them to sign in.
    func restore() async throws {
        try await AppStore.sync()
        await refreshEntitlements()
    }
}

For more detail on a subscription, such as whether it is in a grace period after a failed payment, read its status:

if let statuses = try await product.subscription?.status {
    let entitled = statuses.contains { $0.state == .subscribed || $0.state == .inGracePeriod }
    // In the billing retry period, keep access if you offer it,
    // and ask the person to update their payment method.
}

5. The paywall

SubscriptionStoreView (iOS 17 and later) draws a paywall for a subscription group with the prices, durations and terms in place. You supply the marketing content, show the restore and policy buttons, and point the policy links at your terms and privacy policy.

import SwiftUI
import StoreKit

struct PaywallView: View {
    var body: some View {
        SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
            VStack(spacing: 8) {
                Text("Pro").font(.largeTitle.bold())
                Text("Unlimited projects, synced across your devices.")
                    .foregroundStyle(.secondary)
            }
        }
        .storeButton(.visible, for: .restorePurchases)
        .storeButton(.visible, for: .policies)
        .subscriptionStorePolicyDestination(url: URL(string: "https://example.com/terms")!, for: .termsOfService)
        .subscriptionStorePolicyDestination(url: URL(string: "https://example.com/privacy")!, for: .privacyPolicy)
    }
}

A custom paywall is fine too, as long as it shows everything Apple requires. That list is in paywall best practices. For letting people manage or cancel inside the app, SwiftUI has manageSubscriptionsSheet(isPresented:).

6. The server side

An app that only unlocks features on the device can rely on StoreKit 2 alone. If your backend also needs to know who is subscribed, for example to unlock features on the web, use the App Store Server API and App Store Server Notifications, which tell your server in real time about renewals, refunds, billing problems and offer redemptions.

7. Testing

  • A StoreKit configuration file in Xcode: local products, accelerated renewals, failed payments and refunds, without App Store Connect.
  • The sandbox: your real App Store Connect products, with sandbox test accounts.
  • TestFlight: the full flow on testers’ devices before release.

Test the unhappy paths as carefully as the purchase: cancelled payments, Ask to Buy, a refund, a lapsed card, restoring on a new device.

8. Offers

  • Introductory offers: one per subscription group per customer.
  • Promotional offers: for current and former subscribers, up to 10 per subscription.
  • Offer codes: one-time or custom codes, redeemable in the app with offerCodeRedemption.
  • Win-back offers: Apple shows them to eligible former subscribers.

New for iOS 27

  • Bundles and Suites: several subscriptions as one purchase, across your apps or even other developers’ apps. Access is by request.
  • Multiseat purchases: Volume Purchasing for organisations from 22 October 2026, and Group Purchases later in the winter. They are on by default; you can turn them off in App Store Connect.
  • Monthly with a 12-month commitment: a lower monthly price for a year’s commitment, available outside the United States.
  • Retention Messaging: a message, or an offer, shown when someone chooses to cancel.

What you earn

For auto-renewable subscriptions, you receive 70% of the price, minus taxes, during a subscriber’s first year of paid service, and 85% after that. Members of the App Store Small Business Program receive 85% from the start. Free trials do not count towards the year (Apple).

Related: Paywall best practices · Subscription app development · SwiftUI vs UIKit

FAQ

Quick answers

Not covered here? Write to hello@pull.house.

  • Apple’s Swift API for in-app purchases and subscriptions, introduced in iOS 15. It uses async/await and signed transactions, and replaces the original StoreKit API, which Apple deprecated in iOS 18.

Start a project

Have an iOS app to build?

Tell us what you are making. If we are not the right team for it, we will say so.

Book a 30-min call
hello@pull.house