Skip to main content
3Nsofts logo3Nsofts
App StoreUpdated · August 2026

StoreKit 2: A Free Evaluation Followed by a One-Time Permanent Unlock

Author
Ehsan Azish · 3NSOFTS
Updated
August 2026
Read time
15 min read
Level
Intermediate
Platform
iOS 17+ or macOS 14+, StoreKit 2, App Store Connect

Implementation Notes

  • ~/ What broke: A production edge case that generic tutorials skip.
  • ~/ What to do: Ship the production fix with clear state, errors, and fallback behavior.
StoreKit 2 non-consumableone-time permanent unlock Applefree evaluation StoreKitTransaction currentEntitlementsStoreKit purchase restore

A free download with an app-defined evaluation and a one-time unlock is not a subscription trial. The app grants temporary access itself; after that period, a StoreKit non-consumable permanently unlocks the paid functionality.

The distinction must remain clear in code and product copy:

  • starting the evaluation does not initiate a purchase
  • no payment method is requested by the app to start
  • the evaluation ending does not create a charge
  • the permanent unlock is one purchase, not an auto-renewable subscription

This guide uses 14 days as an example. The architecture works for other evaluation lengths.

Model evaluation and ownership separately

Do not use one Boolean such as isPremium. It hides why access exists and makes expiry, restoration, and support difficult to reason about.

enum AccessState: Equatable {
    case evaluation(daysRemaining: Int)
    case evaluationExpired
    case permanentlyUnlocked
}

Two sources feed that state:

  1. Evaluation start date — app-owned local state.
  2. Purchase entitlement — StoreKit transaction state.

Purchase ownership always wins. A customer with a verified, non-revoked non-consumable entitlement remains unlocked regardless of local evaluation data.

Configure a non-consumable product

Create a non-consumable In-App Purchase in App Store Connect, for example:

com.example.app.permanentUnlock

Do not hardcode a price into the app or website. Load Product and display its localized displayPrice; App Store Connect controls price and storefront localization.

For development, create or sync a .storekit configuration file and select it under the Run scheme's StoreKit Configuration. Local StoreKit configuration data does not upload to App Store Connect.

Store the evaluation start defensively

For an offline macOS or iOS app, the Keychain is preferable to UserDefaults for an evaluation start because ordinary preference deletion should not silently restart access. It is still not a tamper-proof licensing server.

import Foundation
import Security

enum EvaluationError: Error {
    case keychain(OSStatus)
}

struct EvaluationStore {
    private let service = "com.example.app.evaluation"
    private let account = "startedAt"

    func loadStartDate() -> Date? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var item: CFTypeRef?
        guard
            SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
            let data = item as? Data,
            let timestamp = try? JSONDecoder().decode(TimeInterval.self, from: data)
        else { return nil }

        return Date(timeIntervalSince1970: timestamp)
    }

    func startIfNeeded(now: Date = .now) throws -> Date {
        if let existing = loadStartDate() { return existing }

        let data = try JSONEncoder().encode(now.timeIntervalSince1970)
        let attributes: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
            kSecValueData as String: data
        ]

        let status = SecItemAdd(attributes as CFDictionary, nil)
        guard status == errSecSuccess || status == errSecDuplicateItem else {
            throw EvaluationError.keychain(status)
        }
        return loadStartDate() ?? now
    }
}

Choose the Keychain accessibility class according to when the app needs the value. A ThisDeviceOnly item does not migrate to a new device. That makes the evaluation device-local, which must be an intentional product decision.

Calculate evaluation access with Calendar

Define whether “14 days” means fourteen 24-hour intervals or fourteen calendar-day boundaries. For a precise duration:

struct EvaluationPolicy {
    let duration: TimeInterval = 14 * 24 * 60 * 60

    func state(startedAt: Date, now: Date = .now) -> AccessState {
        let remaining = startedAt.addingTimeInterval(duration).timeIntervalSince(now)
        guard remaining > 0 else { return .evaluationExpired }

        let days = Int(ceil(remaining / (24 * 60 * 60)))
        return .evaluation(daysRemaining: days)
    }
}

The wall clock can be changed. An offline evaluation cannot be made perfectly tamper-proof without adding another trusted time source, which changes the privacy and connectivity model. Record suspicious backwards jumps if useful, but avoid locking out legitimate users because of time-zone changes or clock repair.

Derive permanent ownership from StoreKit

StoreKit's Transaction.currentEntitlements sequence includes current non-consumable entitlements. Refunded or revoked products do not appear. Never use a locally cached hasPurchased Boolean as the authority.

import StoreKit
import Observation

@MainActor
@Observable
final class PurchaseController {
    static let unlockID = "com.example.app.permanentUnlock"

    private(set) var product: Product?
    private(set) var ownsPermanentUnlock = false
    private var updatesTask: Task<Void, Never>?

    init() {
        updatesTask = observeTransactionUpdates()
    }

    func prepare() async throws {
        product = try await Product.products(for: [Self.unlockID]).first
        await refreshEntitlements()
    }

    func refreshEntitlements() async {
        var ownsUnlock = false

        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            guard transaction.productID == Self.unlockID else { continue }
            ownsUnlock = true
        }

        ownsPermanentUnlock = ownsUnlock
    }

    private func observeTransactionUpdates() -> Task<Void, Never> {
        Task { [weak self] in
            for await result in Transaction.updates {
                guard !Task.isCancelled else { return }
                guard case .verified(let transaction) = result else { continue }

                if transaction.productID == Self.unlockID {
                    await self?.refreshEntitlements()
                }

                await transaction.finish()
            }
        }
    }
}

Start the updates listener as early as practical. Apple documents that it delivers transactions completed outside the app or on another device, pending transactions that later complete, and unfinished transactions at launch.

Handle every purchase result

extension PurchaseController {
    enum PurchaseError: Error {
        case productUnavailable
        case verificationFailed
        case unknownResult
    }

    enum PurchaseOutcome {
        case unlocked
        case pending
        case cancelled
    }

    func purchaseUnlock() async throws -> PurchaseOutcome {
        guard let product else { throw PurchaseError.productUnavailable }

        switch try await product.purchase() {
        case .success(let result):
            guard case .verified(let transaction) = result else {
                throw PurchaseError.verificationFailed
            }

            ownsPermanentUnlock = true
            await transaction.finish()
            return .unlocked

        case .pending:
            return .pending

        case .userCancelled:
            return .cancelled

        @unknown default:
            throw PurchaseError.unknownResult
        }
    }
}

Do not show .pending as failure. Ask to Buy or another external action may be required, and a later verified transaction arrives through Transaction.updates. Do not show cancellation as an error alert.

Finish a verified transaction after granting access. Never unlock from an unverified transaction merely because it contains the expected product identifier.

Combine the two sources into UI state

func accessState(
    ownsPermanentUnlock: Bool,
    evaluationStart: Date?,
    now: Date = .now
) -> AccessState {
    if ownsPermanentUnlock {
        return .permanentlyUnlocked
    }

    guard let evaluationStart else {
        // Start this at the deliberate product moment, not accidentally here.
        return .evaluation(daysRemaining: 14)
    }

    return EvaluationPolicy().state(startedAt: evaluationStart, now: now)
}

Choose the start moment explicitly. Starting on first launch is simple but may consume days before the user reaches the core feature. Starting when the user first activates evaluation-only functionality is often fairer, but the UI must explain that action.

Provide a restore action

StoreKit 2 normally supplies current entitlements automatically. A visible restore action is still useful and expected in purchase UI. Call AppStore.sync() only after a user explicitly asks because it may prompt for App Store authentication.

func restorePurchases() async throws {
    try await AppStore.sync()
    await refreshEntitlements()
}

“Nothing to restore” is a valid result. Do not create an entitlement because the sync call succeeded; derive it from verified transactions.

Write pricing copy that matches the implementation

Recommended structure:

Try every feature free for 14 days
No payment is required to start, and you will not be charged automatically.

Then unlock it once, forever
When the evaluation ends, choose a one-time permanent purchase to continue. No subscription and no recurring fees.

Avoid:

  • “grace period,” which has a specific subscription meaning
  • “buy now, pay later,” which describes a different financial arrangement
  • a hardcoded price outside StoreKit's localized product display
  • “cancel anytime,” because a non-consumable has nothing to cancel

If the feature uses Apple Music, Spotify, cloud storage, or another service, separately disclose that the user's own account or subscription may be required.

Privacy and support disclosures

State accurately that the evaluation start date is stored locally in the Keychain and purchase ownership is obtained from verified App Store transaction information through StoreKit. Do not claim StoreKit reveals payment-card details; it does not provide those details to the app.

Document these support cases:

  • purchase succeeded but UI did not refresh
  • transaction is pending
  • customer changed App Store account
  • purchase was refunded or revoked
  • new device has the purchase but no local evaluation record
  • app was reinstalled
  • StoreKit product information is temporarily unavailable

The app should remain launchable and explain its state when StoreKit cannot load. A network failure must not erase an already-derived entitlement in the current session.

Test the states, not only the happy path

Use StoreKit Testing in Xcode for fast local work, then the sandbox and TestFlight with App Store Connect products.

Minimum matrix:

  • fresh install before the evaluation starts
  • first evaluation activation
  • final evaluation day and exact expiry boundary
  • device clock moved backward and forward
  • successful verified purchase
  • user cancellation
  • Ask to Buy / pending followed by approval
  • interrupted purchase followed by resolution
  • unverified transaction behavior
  • launch with an unfinished transaction
  • purchase made on another device
  • explicit restore with and without ownership
  • refund or revocation
  • offline launch during evaluation and after a known unlock
  • product-loading failure

Automate state calculation with injected dates. StoreKit Test can automate purchase scenarios, while Xcode's transaction manager can create, delete, approve, refund, and inspect transactions.

Production checklist

  • Configure the unlock as a non-consumable product.
  • Keep evaluation state separate from purchase entitlement.
  • Store the evaluation start at a deliberate product moment.
  • Treat Keychain state as local protection, not perfect anti-tamper proof.
  • Derive ownership from verified StoreKit transactions.
  • Start Transaction.updates early and finish delivered transactions.
  • Handle success, pending, cancellation, unverified, and unknown results.
  • Offer user-initiated restore and refresh entitlements after syncing.
  • Display StoreKit's localized price instead of hardcoding one.
  • Say “no automatic charge” and “no subscription” when those claims are true.
  • Test refunds, pending purchases, reinstalls, offline use, and multiple devices.

References

Related reading

Authoritative References