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
kSecAttrSynchronizableto 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
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
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.
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.updatesat 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.currentEntitlementsis 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
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
- 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.
- Use Debug › StoreKit › Manage Transactions to refund a purchase and confirm the app locks again via
Transaction.updates. - Enable "Ask to Buy" in the configuration to test the pending path.
- On a device, delete the app, reinstall it, and check that
remainingis unchanged. Then callreset()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.