Monetization

StoreKit 2 in Practice: Subscriptions, Lifetime Purchases, and Grace Periods

April 2026 · 9 min read

Apple's StoreKit 2 documentation reads like a spec sheet: thorough on signatures, thin on practice. It tells you what Transaction.updates does. It does not tell you what happens when you forget to start listening at app launch. It explains subscription statuses. It does not explain that a user in a billing grace period should still have access — and that failing to grant it costs you the subscription permanently.

This is what I learned implementing StoreKit 2 across 27 iOS and macOS apps — the patterns that survived production, and the mistakes that cost real revenue.

Why StoreKit 2 Over StoreKit 1

StoreKit 1 required a delegate pattern, a transaction observer, and a receipt validation server. The code was spread across three files minimum, and the SKPaymentTransactionObserver protocol mixed purchase events with restore events with failure events in one noisy callback.

StoreKit 2 replaced all of that with async/await. Buying a product is one line: let result = try await product.purchase(). Checking entitlements is one loop: for await entitlement in Transaction.currentEntitlements. No delegates, no receipt parsing, no server-side validation for most use cases.

The migration table is worth memorizing:

StoreKit 1 StoreKit 2
SKProductsRequest + delegate await Product.products(for:)
SKPaymentQueue.add() await product.purchase()
SKPaymentTransactionObserver Transaction.updates AsyncSequence
Receipt + /verifyReceipt Transaction.currentEntitlements + JWS
restoreCompletedTransactions() AppStore.sync()

The Transaction Listener: Start It at Launch or Lose Money

The single most important StoreKit 2 requirement is this: you must start listening to Transaction.updates when the app launches. Not when the paywall appears. Not when the user taps "Restore Purchases." At launch.

@main
struct MyApp: App {
    let transactionListener = TransactionListener()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    await transactionListener.startListening()
                }
        }
    }
}

actor TransactionListener {
    func startListening() async {
        for await result in Transaction.updates {
            guard case .verified(let transaction) = result else {
                continue
            }
            await handleTransaction(transaction)
            await transaction.finish()
        }
    }

    private func handleTransaction(_ transaction: Transaction) async {
        // Update your entitlement state here
    }
}

Why at launch? Because Transaction.updates delivers transactions that happened while the app was not running. A subscription renewal at 3 AM. A family member's purchase. An Ask to Buy approval. A refund. If you are not listening, these transactions queue up — and unfinished transactions re-deliver on every app launch until you call transaction.finish().

I have seen apps accumulate dozens of unfinished transactions because the listener only started when the paywall opened. Users who never revisited the paywall had a backlog of transactions replaying silently in the background, burning CPU and confusing state logic.

The Subscription State Machine

A subscription in StoreKit 2 is not binary. It is not simply "active" or "expired." There are at least six states that matter for your UI and access control:

  • Subscribed — Active, auto-renewing. Full access.
  • In Grace Period — Payment failed, but Apple gives the user 6-16 days to fix their billing. You should still grant full access. If you cut access here, the user has no reason to fix their payment method, and you lose the subscription entirely.
  • In Billing Retry — Grace period expired, Apple is still retrying the charge for up to 60 days. Granting access here is a judgment call, but reduces involuntary churn.
  • Revoked — Apple refunded the transaction. Remove access immediately.
  • Expired — The subscription ended naturally (user cancelled and period elapsed). Remove access, show re-subscribe option.
  • In Introductory Offer — User is in a free trial or discounted period. Full access, but be aware: introductory offers are only eligible for users who have never subscribed to any product in the same subscription group.

The code to check status:

func checkSubscriptionStatus() async -> Bool {
    for await result in Transaction.currentEntitlements {
        guard case .verified(let transaction) = result else {
            continue
        }

        if let subscription = transaction.subscriptionStatus {
            switch subscription.state {
            case .subscribed, .inGracePeriod:
                return true
            case .inBillingRetryPeriod:
                return true // reduce involuntary churn
            case .revoked, .expired:
                continue
            default:
                continue
            }
        }

        // Non-subscription (lifetime purchase)
        if transaction.revocationDate == nil {
            return true
        }
    }
    return false
}

The critical line is .inGracePeriod returning true. This is where most implementations get it wrong. They check only for .subscribed and cut access for grace period users, triggering a wave of cancellations that should have been recoverable billing failures.

Lifetime Purchases: Simpler Than Subscriptions, With One Catch

For our paid-analogue apps, we use one-time (non-consumable) purchases instead of subscriptions. The user pays once, gets the app forever. StoreKit 2 makes this straightforward:

let product = try await Product.products(for: ["com.techconcepts.app.lifetime"])
if let lifetime = product.first {
    let result = try await lifetime.purchase()
    switch result {
    case .success(let verification):
        let transaction = try verification.payloadValue
        // Grant access
        await transaction.finish()
    case .pending:
        // Ask to Buy or pending approval
        break
    case .userCancelled:
        break
    @unknown default:
        break
    }
}

The catch: .pending. This is the Ask to Buy state, where a child's purchase needs parental approval. If you ignore this case, the purchase silently disappears. The parent approves hours later, the approval arrives via Transaction.updates, and if you are not listening (see above), the user has paid but has no access. This is the kind of support email that erodes trust.

Sandbox Testing: The Parallel Universe

Apple's sandbox environment for StoreKit is a parallel universe where subscriptions renew in minutes instead of months. A monthly subscription renews every 5 minutes. A yearly subscription renews every hour. This is documented, but the behavioral differences are not:

  • Sandbox subscriptions auto-renew only 6 times, then expire. If your test subscription stops renewing, this is not a bug — create a new sandbox account.
  • Grace periods do not exist in sandbox. You cannot test grace period handling in sandbox. Use StoreKit Configuration files in Xcode to simulate billing retry states.
  • Introductory offers work once per sandbox account, just like production. If you used a free trial on a sandbox account, that account is permanently ineligible for trials on that subscription group.
  • Sandbox transactions may take 30-60 seconds to appear after purchase. Do not panic-debug your paywall — wait.

For local development, I recommend StoreKit Configuration files (.storekit files in Xcode) over the sandbox environment. They are faster, offline, and let you simulate states like billing retry and revocation that sandbox cannot reproduce.

The RevenueCat Question

We use RevenueCat through our shared AppFoundation package across all 27 apps. RevenueCat wraps StoreKit 2 (and falls back to StoreKit 1 for older iOS versions), adds server-side receipt validation, cross-platform entitlement management, and a paywall builder.

Is it worth the abstraction? For a portfolio of apps, yes. RevenueCat's value is not in the purchase flow — StoreKit 2 handles that well enough alone. Its value is in the analytics dashboard, the subscription status webhook, and the fact that entitlement state is available server-side without building your own receipt validation infrastructure.

For a single app, StoreKit 2 alone is sufficient. The API is clean, the JWS verification is built in, and you avoid a third-party dependency. For a portfolio where you need to see subscription metrics across multiple apps in one dashboard, RevenueCat pays for itself.

27
Apps using StoreKit 2
3
Purchase types (monthly, yearly, lifetime)
6
Subscription states to handle

The finish() Rule

Every verified transaction must be finished. This is the most violated rule in StoreKit 2 implementations. If you do not call transaction.finish(), the transaction re-delivers on the next app launch. And the next. And the next.

The instinct is to finish the transaction only after confirming that your entitlement state has been persisted (to a database, to UserDefaults, to your server). This is correct — but the trap is forgetting to finish in error paths. If your persistence layer throws and you bail out without finishing, the transaction lives forever.

// The safe pattern
do {
    let transaction = try verification.payloadValue
    try await persistEntitlement(for: transaction)
    await transaction.finish() // Always reached
} catch {
    // Persist failed, but still finish to prevent re-delivery
    let transaction = try? verification.payloadValue
    await transaction?.finish()
    // Log the error, retry persistence separately
}

Some teams defer finishing until server-side confirmation. This works, but means unfinished transactions pile up if the server is unreachable. For client-only apps with no backend, finish immediately after updating local state.

Key Takeaways

  • Start Transaction.updates at app launch, not at paywall presentation. Unfinished transactions accumulate and cause state corruption.
  • Grant access during grace period and billing retry. Cutting access during billing failures converts recoverable churn into permanent cancellations.
  • Always call transaction.finish() — including in error paths. Unfinished transactions re-deliver on every launch.
  • Handle the .pending case for Ask to Buy. The approval arrives asynchronously via Transaction.updates.
  • Use StoreKit Configuration files for local testing. Sandbox cannot simulate grace periods or revocations.
  • RevenueCat is worth it for portfolios, not for single apps. StoreKit 2 alone is clean enough for standalone projects.

Download: StoreKit 2 Implementation Checklist

The checklist I use before every App Store submission. Covers transaction listener, entitlement states, sandbox testing, and common rejection causes.

Related Posts

Building 27 Apps with One Shared Swift Package

How AppFoundation centralizes StoreKit, analytics, and crash reporting for every app.

Why We Build Paid Alternatives to Our Own Free Apps

The paid-analogue strategy: one-time purchases alongside subscriptions.

Evgeny Goncharov - Founder of TechConcepts, ex-Yandex, ex-EY, Darden MBA

Evgeny Goncharov

Founder, TechConcepts

I build automation tools and custom software for businesses. Previously at Yandex (Search) and EY (Advisory). Darden MBA. Based in Madrid.

About me LinkedIn GitHub
← All blog posts

Need help with iOS or macOS app monetization?

15 minutes. No pitch. Just honest advice on whether I can help.

Book a Call