StoreKit subscriptions in the real world: entitlements, edge cases, and recovery strategies
A practical StoreKit 2 approach for subscription gating that survives renewals, grace periods, restores, and the weird stuff you only see after launch.
Subscriptions are easy in demos.
In production they fail in the exact ways your happy-path architecture quietly assumes will never happen:
- the renewal succeeds, but your app does not notice
- the renewal fails, but the user should still have access (grace period)
- the user upgrades, but your “current plan” UI keeps showing the old product
- the user gets a refund, and you keep shipping premium like it’s a charity
If you treat a subscription as “a boolean stored in UserDefaults”, you will ship entitlement bugs. Some of them will look like revenue drops. The rest will look like angry emails.
This post is the pragmatic StoreKit 2 approach I use: build around entitlements, treat everything else as signals, and add a recovery loop so your app self-heals.
Your actual job: answer one question
When the app is making an access decision, you want to answer:
“Does this Apple ID have an active entitlement for this subscription group right now?”
Not:
- “Did the user purchase at some point?”
- “Did my backend say they were pro last week?”
- “Did I see a transaction when the paywall closed?”
StoreKit 2 gives you the right primitive: Transaction.currentEntitlements.
Minimal entitlement gate
import StoreKit
enum ProEntitlement {
static let productIDs: Set<String> = [
"com.vburojevic.app.pro.monthly",
"com.vburojevic.app.pro.yearly"
]
static func hasPro() async -> Bool {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
if productIDs.contains(transaction.productID) {
return transaction.revocationDate == nil
}
}
return false
}
}
This is not the whole story, but it’s the correct center of gravity. Everything else is there to keep this answer fresh.
The failure mode you will ship if you are not careful
Here is a real bug that shows up in the wild:
- user buys monthly
- later, user upgrades to yearly
- your UI still shows monthly as “active”
- your access gate is correct, but your product state is wrong
The root cause is usually one of these:
-
You cached a
Productand never refreshed it. -
You derived “current plan” from the last purchase you saw (paywall close event), not from entitlements.
-
You ignored
Transaction.updates, so you never processed the upgrade transaction.
How I diagnosed it
I add two layers of visibility:
-
Structured logs around every entitlement refresh.
-
A debug screen that prints the verified entitlement set and the most recent verified transaction per product.
Example using Logger:
import os
private let log = Logger(subsystem: "com.vburojevic.app", category: "storekit")
func logEntitlementSnapshot(_ entitlements: [Transaction]) {
let ids = entitlements.map { $0.productID }.joined(separator: ",")
log.info("Entitlements verified: \(ids, privacy: .public)")
}
When the bug happened, logs clearly showed Transaction.updates delivered the upgrade, but my UI state never reloaded the “plan” label because it was bound to a stale model.
Fix: derive the label from the same source of truth as the gate. If the gate answers “pro”, the UI should show the entitlement that made it true.
Architecture that survives reality
I like a single component that owns StoreKit concerns. Call it EntitlementStore. It has three responsibilities:
- fetch products (UI)
- keep entitlements up to date (access gate)
- publish a compact state for the app
State model
Keep it boring and serializable.
struct SubscriptionState: Equatable {
var isPro: Bool
var activeProductID: String?
var lastRefreshed: Date
}
Refresh loop
- Refresh on app launch
- Refresh on foreground
- Refresh when
Transaction.updatesemits
The refresh should be idempotent and safe to call repeatedly.
final class EntitlementStore: ObservableObject {
@MainActor @Published private(set) var state = SubscriptionState(
isPro: false,
activeProductID: nil,
lastRefreshed: .distantPast
)
private var updatesTask: Task<Void, Never>?
func start() {
updatesTask?.cancel()
updatesTask = Task { await observeUpdates() }
Task { await refresh(reason: "start") }
}
deinit { updatesTask?.cancel() }
func refreshOnForeground() {
Task { await refresh(reason: "foreground") }
}
private func observeUpdates() async {
for await update in Transaction.updates {
guard case .verified(let transaction) = update else { continue }
await transaction.finish()
await refresh(reason: "transaction_update")
}
}
private func refresh(reason: String) async {
let now = Date()
var verified: [Transaction] = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
verified.append(transaction)
}
let active = verified
.filter { $0.revocationDate == nil }
.sorted { $0.purchaseDate > $1.purchaseDate }
.first
await MainActor.run {
state = SubscriptionState(
isPro: active != nil,
activeProductID: active?.productID,
lastRefreshed: now
)
}
log.info("Refresh(\(reason, privacy: .public)) pro=\(active != nil, privacy: .public) active=\(active?.productID ?? "nil", privacy: .public)")
}
}
Opinionated notes:
- I finish verified
Transaction.updatestransactions promptly. If you forget this, StoreKit will keep delivering them. - I do not store “isPro” in persistent storage as a source of truth. Persisting it can be useful for UI optimism, but only if you treat it as a cache with a short half-life.
Edge cases worth handling explicitly
1) Billing retry, grace period, and “still allowed”
The user can be in billing retry or grace period. Depending on your business rules, you might still want to allow access.
StoreKit 2 gives you relevant fields and status signals, but the simplest practical approach is:
- Gate primarily on verified entitlements.
- When entitlement disappears, show a clear “we could not verify” state, then offer a retry and a restore.
If you want finer control, use Product.SubscriptionInfo.Status to distinguish states.
2) Refunds and revocations
If a transaction is revoked, you should stop granting access.
That is why in the code above I check revocationDate == nil.
Do not ignore this. People will find out they can refund and keep access, then it becomes a hobby.
3) Family Sharing expectations
If you enable Family Sharing, make sure your entitlement check is based on StoreKit entitlements, not on your backend “user id”. If you tie access purely to your account system, you will break legitimate family entitlements.
4) Offline mode
StoreKit cannot always refresh immediately. Network is optional in the user experience, not in your app logic.
My rule:
- If the last verified entitlement was recent (for example within 24 hours), allow access while offline.
- If it is stale, degrade: keep the app usable but lock premium features until verification succeeds.
This is a product decision. The engineering part is: track lastRefreshed, and expose a “stale” flag to UI.
Restore is not a button, it’s a strategy
The “Restore Purchases” button is a coping mechanism for missing refresh logic.
In StoreKit 2, a restore flow is usually:
- call
AppStore.sync() - re-run your entitlement refresh
func restore() async {
do {
try await AppStore.sync()
await refresh(reason: "restore")
} catch {
log.error("Restore failed: \(String(describing: error), privacy: .public)")
}
}
If restore is the only way entitlements update, you have an observation bug, not a restore problem.
Verification step: prove it works with StoreKitTest and CI
If you rely on manual sandbox poking, you will regress entitlements.
The verification loop I like:
- A StoreKitTest configuration with products for your subscription group.
- A small integration test target that drives “purchase”, then validates app state.
- CI output that prints the entitlement snapshot after each step.
In Xcode, StoreKitTest can simulate renewals, cancellations, and refunds. The key is that your test asserts on your SubscriptionState, not on internal StoreKit objects.
Pseudo-test outline:
func testUpgradeMonthlyToYearlyUpdatesActiveProduct() async {
let store = EntitlementStore()
store.start()
await purchase("monthly")
await store.refreshForTest()
XCTAssertEqual(store.state.activeProductID, "com.vburojevic.app.pro.monthly")
await purchase("yearly")
await store.refreshForTest()
XCTAssertEqual(store.state.activeProductID, "com.vburojevic.app.pro.yearly")
}
In CI, I want to see something concrete, not vibes. For example:
- test log line:
Refresh(transaction_update) pro=true active=com...yearly - test report: all passing
If the upgrade bug returns, it will fail loudly and early.
What I avoid
- Deriving entitlement from
UserDefaultsand hoping it stays correct. - Using only the paywall close event as proof of purchase.
- Relying on a backend receipt check for the app gate. Backend validation can be great for fraud detection and cross-platform, but the device still needs a robust local truth.
Practical checklist
If you want the short list, it is this:
- Gate features using
Transaction.currentEntitlements. - Observe
Transaction.updates, finish verified transactions, and refresh. - Build a refresh loop for launch, foreground, and updates.
- Log entitlement snapshots with
Loggerso you can debug production. - Verify with StoreKitTest and an automated test that asserts on your app state.
Subscriptions are not hard. They are just hostile to wishful thinking.