Skip to content
← All guides iOS

StoreKit 2 Subscriptions: A Complete Guide

A practical guide to implementing StoreKit 2 subscriptions in iOS apps — async/await API, entitlement checks, receipt validation, and monetization tips.

StoreKit 2 Subscriptions: A Complete Guide

StoreKit 2 subscriptions are the foundation of sustainable iOS monetization in 2026. If you’re shipping a new app — or modernizing an existing one — understanding the StoreKit 2 API will save you days of debugging, eliminate server-side receipt validation code, and let you build a cleaner purchase flow in far fewer lines of Swift.

This guide covers everything from product loading to subscription status checks, with the patterns we use in our own apps.

Why StoreKit 2 Matters

The original StoreKit (now called StoreKit 1) was powerful but painful. Verifying a receipt meant sending a Base64-encoded blob to your server, calling Apple’s verify-receipt endpoint, and writing non-trivial parsing logic to figure out whether a subscription was actually active.

StoreKit 2, released with iOS 15, rewrites that story:

  • Native async/await API — no more delegate callbacks or completion-handler pyramids.
  • On-device transaction verification — Apple signs every Transaction with a JWS signature; Swift verifies it automatically.
  • Product.SubscriptionInfo — a first-class type for querying renewal status, billing retry state, grace period, and more.
  • Transaction.currentEntitlements — a single async sequence that tells you exactly what a user has access to right now, no server required for most apps.

The result is that a clean subscription implementation is now roughly 80–120 lines of Swift instead of several hundred.

Setting Up Products in App Store Connect

Before writing a line of code, configure your subscription groups in App Store Connect:

  1. Create a Subscription Group (e.g., “Pro Access”).
  2. Add individual subscription products inside it — monthly and annual are the most common pairing.
  3. Give each product a clear display name and description; StoreKit 2 surfaces these directly in the UI without extra metadata fetching.
  4. Set up a StoreKit Configuration File in Xcode (File → New → StoreKit Configuration File) so you can test the full purchase flow in the simulator without real money.

Tip: Enable Ask to Buy testing in the Xcode configuration file early. It exercises the deferred purchase path that trips up many teams right before App Review.

Loading Products

import StoreKit

let productIDs = ["com.yourapp.pro.monthly", "com.yourapp.pro.annual"]

let products = try await Product.products(for: productIDs)

Product.products(for:) returns an array of Product values in whatever order Apple returns them — sort by price or by your preferred display order before rendering. Each Product carries .displayName, .displayPrice, and .description already localized to the user’s storefront.

Purchasing a Subscription

func purchase(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase()

    switch result {
    case .success(let verification):
        // StoreKit 2 verifies the JWS signature automatically.
        let transaction = try checkVerified(verification)
        await transaction.finish()
        return transaction
    case .userCancelled, .pending:
        return nil
    @unknown default:
        return nil
    }
}

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

Calling transaction.finish() is mandatory. An unfinished transaction re-enters the queue on every app launch until you call it.

Checking Entitlements on App Launch

This is the part that trips up most developers. You must check entitlements every time the app launches — not just after a purchase — because subscriptions can expire, renew, or be cancelled at any time.

func updateCustomerProductStatus() async {
    var purchasedSubscriptions: [Product] = []

    for await result in Transaction.currentEntitlements {
        guard case .verified(let transaction) = result else { continue }

        if transaction.productType == .autoRenewable,
           let product = products.first(where: { $0.id == transaction.productID }) {
            purchasedSubscriptions.append(product)
        }
    }

    self.purchasedSubscriptions = purchasedSubscriptions
}

Transaction.currentEntitlements is an AsyncSequence that yields one verified transaction per active product. If it yields nothing, the user has no active subscription — no server call needed.

Listening for Transaction Updates

Subscriptions renew in the background. Your app needs to listen for these updates so entitlements stay current across sessions:

var updateListenerTask: Task<Void, Error>?

func startListeningForTransactions() {
    updateListenerTask = Task.detached(priority: .background) {
        for await result in Transaction.updates {
            guard case .verified(let transaction) = result else { continue }
            await self.updateCustomerProductStatus()
            await transaction.finish()
        }
    }
}

Start this task in your @main App struct or AppDelegate, and cancel it on deinit.

Subscription Status: Beyond Active/Inactive

StoreKit 2 gives you fine-grained status through Product.SubscriptionInfo.Status:

StatusMeaning
.subscribedActive and will renew
.expiredLapsed — show win-back offer
.inBillingRetryPeriodPayment failed; Apple is retrying
.inGracePeriodPayment failed; access continues temporarily
.revokedRefunded or revoked by Apple

The billing retry and grace period states are especially important. During grace period the user still has access — you should not lock them out. During billing retry they don’t, but you should show a soft paywall with a “Update Payment Method” deep link rather than a hard block.

let statuses = try await product.subscription?.status ?? []
for status in statuses {
    switch status.state {
    case .inGracePeriod:
        // keep access, show gentle banner
    case .inBillingRetryPeriod:
        // soft paywall, prompt payment update
    default:
        break
    }
}

Displaying Subscription Offers

In 2026, introductory and promotional offers are table stakes for subscription apps. StoreKit 2 makes them straightforward:

if let intro = product.subscription?.introductoryOffer {
    // intro.type is .introductory, .promotional, or .winBack
    // intro.period, intro.price, intro.paymentMode
    Text("Start free for \(intro.period.debugDescription)")
}

You can also check whether a user is eligible for an introductory offer before showing it:

let isEligible = await product.subscription?.isEligibleForIntroOffer ?? false

Show the introductory price prominently if eligible — conversion rates on free trials are significantly higher than on direct paid prompts.

Do You Still Need Server-Side Validation?

For most apps: no. On-device verification via VerificationResult is cryptographically sound and Apple-maintained. Where you still want server-side logic:

  • Granting access across multiple platforms (web, Android, etc.) where a canonical source of truth is needed.
  • Fraud signals and analytics beyond what Apple provides.
  • Webhook-driven entitlement updates via App Store Server Notifications v2, which deliver signedTransactionInfo JWS payloads that use the same verification model.

If you do implement server-side validation, Apple’s App Store Server API (also StoreKit 2-era) replaces the old verifyReceipt endpoint entirely.

Common Mistakes to Avoid

  • Not calling transaction.finish() — the most common bug; causes duplicate purchase prompts.
  • Checking entitlements only after purchase — renewal and revocation events happen silently; always check on launch.
  • Blocking UI during Product.products(for:) — this is a network call; show a loading state.
  • Hardcoding product IDs — keep them in a config file or remote config so you can add products without an app update.
  • Ignoring grace period — locking users out during grace period increases churn and generates App Review rejections.

Monetization Context: Is Subscription Right for Your App?

Subscriptions make sense when your app delivers ongoing value — new content, synced data, AI features, or a live service. For a one-time utility, a single in-app purchase or upfront price may convert better. We cover the full picture in our iOS services overview and regularly see subscription ARPU outperform one-time purchases within 18 months for content and AI-driven apps.

The apps we’ve shipped — including an AI kitchen assistant and an Uzbek-language AI product — all use subscription monetization, and getting the StoreKit 2 implementation right from day one was a meaningful factor in their retention metrics.

What a Clean Implementation Looks Like

A production-ready StoreKit 2 subscription manager involves:

  1. A StoreManager observable class that owns products, purchasedSubscriptions, and the update listener task.
  2. updateCustomerProductStatus() called on init and whenever the app foregrounds.
  3. Clear UI states for loading, subscribed, expired, grace period, and billing retry.
  4. Introductory offer eligibility checks before rendering paywalls.
  5. A /restore path (simply re-running updateCustomerProductStatus() after AppStore.sync()) for users who reinstall.

The full implementation sits comfortably under 200 lines. If yours is growing beyond that, look for places where UI logic has leaked into the store layer.


Building a subscription app and want an experienced team to get the StoreKit 2 implementation right from the start? Reach out to us — we’re happy to talk through the architecture before you write a line of purchase code.

Building something like this?

Fera Tech ships iOS & full-stack apps end-to-end. Tell us about your project.

Start a project
Call us Open business Telegram