OnDevice Kit

A free trial that survives reinstalling: Keychain and StoreKit 2

"The first two are free" is a fair offer, as long as deleting and reinstalling the app doesn't turn two into infinity. Here is a small, honest implementation in Swift.

A one-time purchase with a few free uses is a good fit for on-device AI apps. There is no server cost to cover with a subscription, and people get to see the feature work on their own data before they pay. The usual implementation keeps the counter in UserDefaults. That counter disappears when the app is deleted, and the trial starts over.

The Keychain behaves differently. This guide stores the trial counter there, sells the unlock as a StoreKit 2 non-consumable, and handles restore, Family Sharing, Ask to Buy and refunds. No server, no receipt validation code of your own.

Why the Keychain, and what it does not promise

Generic-password Keychain items are stored outside your app's container. On current iOS versions, they remain on the device after the app is deleted, and a reinstalled app with the same bundle identifier and team can read them again. Erasing the device removes them.

Be clear about what that is: observed, long-standing behavior, not a documented guarantee. Apple briefly changed it in an iOS 10.3 beta and reverted. Build your trial so that if the counter ever does reset, the worst case is a person getting their free uses again. That is a fine worst case.

Two more properties to know:

  • The item is per device. A person with an iPhone and an iPad gets the free uses on each. You could set kSecAttrSynchronizable to sync it through iCloud Keychain, but then it depends on the person's iCloud settings, and it is rarely worth it for a counter.
  • Use kSecAttrAccessibleAfterFirstUnlock. The default (WhenUnlocked) makes the item unreadable while the device is locked, which breaks the first background task that checks the quota.

A tiny Keychain wrapper

Keychain.swift
import Foundation
import Security

enum Keychain {
    static var service: String { Bundle.main.bundleIdentifier ?? "app" }

    static func data(_ key: String) -> Data? {
        var query = base(key)
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne
        var out: AnyObject?
        return SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess ? out as? Data : nil
    }

    static func set(_ data: Data?, for key: String) {
        let query = base(key)
        SecItemDelete(query as CFDictionary)
        guard let data else { return }
        var add = query
        add[kSecValueData as String] = data
        add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
        SecItemAdd(add as CFDictionary, nil)
    }

    static func int(_ key: String) -> Int {
        data(key).flatMap { Int(String(decoding: $0, as: UTF8.self)) } ?? 0
    }

    static func set(_ value: Int, for key: String) {
        set(Data(String(value).utf8), for: key)
    }

    private static func base(_ key: String) -> [String: Any] {
        [kSecClass as String: kSecClassGenericPassword,
         kSecAttrService as String: service,
         kSecAttrAccount as String: key]
    }
}

Delete-then-add is simpler than SecItemUpdate and fine for a value written a handful of times. The service is the bundle identifier, so two of your apps never share a counter by accident.

The free quota

FreeQuota.swift
import Observation

@MainActor
@Observable
final class FreeQuota {
    let key: String
    let free: Int
    private(set) var used: Int

    init(_ name: String, free: Int) {
        key = "quota.\(name)"
        self.free = free
        used = Keychain.int("quota.\(name)")
    }

    var remaining: Int { max(0, free - used) }
    var isAvailable: Bool { remaining > 0 }

    func use(_ count: Int = 1) {
        used += count
        Keychain.set(used, for: key)
    }

    #if DEBUG
    func reset() { used = 0; Keychain.set(nil, for: key) }
    #endif
}

Count a use after the work succeeded, not before. If the model fails or the person cancels, they should not lose one of their free tries. And keep reset() in debug builds: you will need it, because deleting the app no longer clears the trial on your own test device either.

A variation for features that should stay free in small doses is "once a day": store the last day used as a "yyyy-M-d" string in the same Keychain and compare it with today.

The unlock: a StoreKit 2 non-consumable

StoreKit 2 verifies transactions on the device with signed JWS payloads, so for a one-time unlock you do not need a server. The class below loads the product, buys it, restores it and listens for changes that happen outside your purchase flow.

Unlock.swift
import StoreKit
import Observation

@MainActor
@Observable
final class Unlock {
    let productID: String
    private(set) var product: Product?
    private(set) var isUnlocked: Bool
    private(set) var isPurchasing = false
    var price: String? { product?.displayPrice }

    @ObservationIgnored private var updates: Task<Void, Never>?
    private var cacheKey: String { "unlock.\(productID)" }

    init(_ productID: String) {
        self.productID = productID
        // Last known state first, so the UI doesn't flash a paywall at launch.
        isUnlocked = UserDefaults.standard.bool(forKey: "unlock.\(productID)")
        updates = Task { [weak self] in
            for await result in Transaction.updates {
                if case .verified(let t) = result { await t.finish() }
                await self?.refresh()
            }
        }
        Task { await load() }
    }

    func load() async {
        product = try? await Product.products(for: [productID]).first
        await refresh()
    }

    /// Re-reads entitlements: catches refunds, Family Sharing and Ask to Buy approvals.
    func refresh() async {
        var owned = false
        for await result in Transaction.currentEntitlements {
            if case .verified(let t) = result, t.productID == productID, t.revocationDate == nil {
                owned = true
            }
        }
        set(owned)
    }

    @discardableResult
    func buy() async throws -> Bool {
        if product == nil { await load() }
        guard let product else { throw StoreError.productNotFound }
        isPurchasing = true
        defer { isPurchasing = false }
        switch try await product.purchase() {
        case .success(.verified(let t)):
            await t.finish()
            set(true)
            return true
        case .success(.unverified):
            throw StoreError.unverified
        default:
            return false          // .pending (Ask to Buy) or .userCancelled
        }
    }

    func restore() async throws {
        try await AppStore.sync()
        await refresh()
        if !isUnlocked { throw StoreError.nothingToRestore }
    }

    private func set(_ owned: Bool) {
        isUnlocked = owned
        UserDefaults.standard.set(owned, forKey: cacheKey)
    }
}

enum StoreError: LocalizedError {
    case productNotFound, unverified, nothingToRestore
    var errorDescription: String? {
        switch self {
        case .productNotFound: "The App Store can't be reached right now."
        case .unverified: "The purchase couldn't be verified."
        case .nothingToRestore: "No purchase was found for this Apple Account."
        }
    }
}

A few decisions in there are worth explaining:

  • Start listening to Transaction.updates at launch. An Ask to Buy approval, a purchase on another device or a refund arrives there, possibly while your purchase screen is not open. Apple logs a warning if you never iterate it.
  • Check revocationDate. Refunded or revoked purchases can still show up as transactions; a revoked one must not unlock anything.
  • Cache the last state in UserDefaults. currentEntitlements is asynchronous. Without the cache, paying customers see the paywall for a moment on every launch. This cache is a display hint, not the source of truth: refresh() overwrites it.
  • Family Sharing needs no code. If you enable it for the product in App Store Connect, family members' entitlements appear in currentEntitlements.

Restore is required

App Review guideline 3.1.1 requires a restore mechanism for non-consumable purchases. Show a visible "Restore purchase" button on the paywall and in settings. AppStore.sync() asks the person to authenticate, so call it only from that button, never automatically at launch.

Gating the feature

LetterView.swift
import SwiftUI

struct LetterView: View {
    @State private var quota = FreeQuota("letters", free: 2)
    @State private var unlock = Unlock("com.example.app.unlimited")
    @State private var showPaywall = false
    let text: String

    var body: some View {
        VStack(spacing: 16) {
            Button("Read this letter") { Task { await analyze() } }
            if !unlock.isUnlocked {
                Text("\(quota.remaining) free letters left").font(.footnote)
            }
        }
        .sheet(isPresented: $showPaywall) {
            VStack(spacing: 12) {
                Button("Unlock for \(unlock.price ?? "…")") {
                    Task { if (try? await unlock.buy()) == true { showPaywall = false } }
                }
                .disabled(unlock.isPurchasing)
                Button("Restore purchase") {
                    Task { try? await unlock.restore(); if unlock.isUnlocked { showPaywall = false } }
                }
            }
            .padding()
        }
    }

    private func analyze() async {
        guard unlock.isUnlocked || quota.isAvailable else { showPaywall = true; return }
        do {
            try await runModel(on: text)     // your feature
            if !unlock.isUnlocked { quota.use() }
        } catch {
            // failed work does not count against the trial
        }
    }

    private func runModel(on text: String) async throws { /* ... */ }
}

In a real app, create Unlock once at the app level and pass it down, so there is a single Transaction.updates listener.

Testing

  1. Add a StoreKit configuration file in Xcode with the non-consumable and select it in the scheme. Purchases then run locally, including in the Simulator.
  2. Use Debug › StoreKit › Manage Transactions to refund a purchase and confirm the app locks again via Transaction.updates.
  3. Enable "Ask to Buy" in the configuration to test the pending path.
  4. On a device, delete the app, reinstall it, and check that remaining is unchanged. Then call reset() from a debug menu.

If the feature behind the paywall is an on-device model, the other guides cover the rest of the path: downloading the model in the background and using Apple Intelligence where it exists, with a fallback.