When StoreKit 2 Users Say They Paid but Can't Access — Field Notes on Subscription Entitlement Drift
StoreKit 2 subscriptions are harder to operate than to implement. This is a record of the drift between currentEntitlements, subscription.status, and server notifications — and the reconcile logic that finally stopped the support tickets.
One morning a support message arrived: "I bought the yearly plan, but the premium features are still locked." The receipt screenshot showed a completed purchase. App Store Connect counted the sale. And yet, on that person's device, the app had decided they were not a subscriber.
Every StoreKit 2 sample looks elegant — a few lines of try await product.purchase() and you're done. But the genuinely hard part in production isn't the moment of purchase. It's answering, consistently on both the device and the server, the question "does this person hold a valid entitlement right now?" These are my notes on reducing that mismatch — entitlement drift — in the order I actually hit each pitfall.
Treat "purchase" and "entitlement" as different things
The first trap was in the vocabulary of the design. Many starter implementations keep a purchasedProductIDs set and insert a product ID when a purchase succeeds. But a purchase is a one-time event, whereas an entitlement is a state that changes over time. Auto-renewable subscriptions quietly renew each month, expire, get refunded, and hang in limbo during Billing Retry. Collecting purchase events alone can't express the entitlement at this exact moment.
So I moved the app's source of truth from "purchase history" to "current entitlement," and separated them at the type level.
// Expresses only "what can be used right now" — not purchase historystruct Entitlement: Equatable { enum State: Equatable { case active(until: Date?) // valid (until == nil means non-consumable) case inGracePeriod(until: Date) // payment retry in progress — keep access case expired } let productID: String let state: State}
Building inGracePeriod into the type from the start paid off later. If you treat a refund or failed payment as "immediately invalid," you end up locking out users to whom Apple is still extending grace — your app gets ahead of Apple and slams the door.
Don't make currentEntitlements your only truth
In StoreKit 2, the obvious way to read current entitlements is Transaction.currentEntitlements. I trusted it alone at first. But tracing support cases revealed how often currentEntitlementsonly reflects that device's local cache. A renewal happened on another device, the network hiccuped, a signature check failed and got swallowed — for various reasons, you get a state where "Apple's servers know it's valid, but this device's currentEntitlements doesn't show it."
So I layered three sources in order of trust.
Source
Answers
Weakness
Transaction.currentEntitlements
Valid transactions this device knows about
Local-cache dependent; can miss
Product.SubscriptionInfo.Status
Renewal state, grace, expiration reason
Assumes the product is already loaded
App Store Server Notifications V2
Server-confirmed facts (refund, expiry, renewal)
Requires your own endpoint
I understand the urge to avoid running a server as a solo developer. Even so, the recurrence of "I paid but can't use it" only truly stopped once I held one piece of server-side truth. Even a minimal setup — receiving Apple's notifications and recording the last confirmed state — lets you reconcile the device's claim against the server's facts.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How to decide which of currentEntitlements, subscription.status, or server notifications is your source of truth, plus a startup reconcile that rebuilds entitlement state from scratch
✦The three ways Transaction.updates silently goes missing — listener lifetime, updates while the app is closed, and forgotten finish() — and how to close each one
✦An entitlement model that treats refunds, expirations, and Billing Retry with grace rather than instant lockout, and how to tell sandbox behavior apart from a real bug
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
A design that updates entitlements only at the moment of purchase misses all the renewals that happen "while the app is closed." I didn't stop updating on purchase, but I added a separate reconcile that rebuilds entitlements from scratch on every foreground entry.
@MainActorfinal class EntitlementStore: ObservableObject { @Published private(set) var entitlements: [String: Entitlement] = [:] private var updates: Task<Void, Never>? func start() { // Call on launch and on every foreground return Task { await reconcile() } updates = listenForUpdates() } // Rebuild current entitlements from device data — replace, don't diff func reconcile() async { var rebuilt: [String: Entitlement] = [:] for await result in Transaction.currentEntitlements { guard case .verified(let txn) = result else { continue } // never accept unverified // Skip revoked (refunded or replaced by an upgrade) if let revoked = txn.revocationDate, revoked <= Date() { continue } rebuilt[txn.productID] = Entitlement( productID: txn.productID, state: await resolveState(for: txn) ) } entitlements = rebuilt // wholesale replace — no stale entitlement survives } // Fill in renewal/expiry detail from subscription.status private func resolveState(for txn: Transaction) async -> Entitlement.State { guard txn.productType == .autoRenewable, let product = try? await Product.products(for: [txn.productID]).first, let status = try? await product.subscription?.status.first, case .verified(let renewal) = status.renewalInfo else { return .active(until: txn.expirationDate) } switch status.state { case .subscribed, .inBillingRetryPeriod: // Even during retry, keep access while still within grace if let until = renewal.gracePeriodExpirationDate { return .inGracePeriod(until: until) } return .active(until: txn.expirationDate) case .inGracePeriod: return .inGracePeriod(until: txn.expirationDate ?? Date()) default: return .expired } }}
Two points matter. Don't add and subtract by diff — replace rebuilt wholesale every time. And read subscription.status so you don't instantly lock out users in payment retry. Once those two were in, the "I renewed but got dropped to free" tickets essentially disappeared.
Three ways Transaction.updates goes missing
The real-time Transaction.updates stream is your lifeline for external events — Family Sharing purchases, renewals on another device, refunds. But there were three ways it silently went missing.
The first is listener lifetime. If you spin up the listener with Task.detached but the owner holding it is released first, the whole stream vanishes. I once started the listener at view scope and produced a hard-to-reproduce bug where only users who closed that screen stopped receiving updates. The listener belongs at app lifetime — a single object directly under App.
The second is updates that happen while the app isn't running. Transaction.updates streams events that occur while the app is alive; updates during the closed period don't reliably replay on next launch. Running reconcile() on every launch (previous section) exists to plug this hole.
The third is a forgotten finish(). A transaction you've verified and reflected into entitlements must be completed with await transaction.finish(), or StoreKit considers it "not yet processed" and re-injects the same transaction into updates on every launch. I once buried finish deep inside a branch so one path never called it. When verification succeeds, reflecting the entitlement and calling finish should be one unit, written before any early return.
private func listenForUpdates() -> Task<Void, Never> { Task.detached { [weak self] in for await update in Transaction.updates { guard case .verified(let txn) = update else { continue } await self?.reconcile() // rebuild state first await txn.finish() // always finish (before any early return) } }}
Keep some distance between Restore and AppStore.sync()
You often see "Restore Purchases" wired straight to AppStore.sync() (I did this for years). But AppStore.sync() is a forced re-sync with the App Store account that can trigger a sign-in dialog. What users usually want isn't to log in again — it's "please check the entitlement I already have."
So I made restore a two-step flow. Pressing the button runs reconcile() first, and only if no entitlement is found does it offer AppStore.sync(). The number of dialogs dropped, easing both "I pressed restore and nothing happened" and "it asked me to log in out of nowhere."
func restore() async -> Bool { await reconcile() if !entitlements.isEmpty { return true } // local data was enough try? await AppStore.sync() // only now force a sync await reconcile() return !entitlements.isEmpty}
Don't turn refunds and expirations into instant free tier
Dropping a transaction with a revocationDate from entitlements is correct — but how you drop it needs care. If you delete in-app artifacts (downloaded content and so on) the instant a refund lands, a Family Sharing reversal or an accidental refund leaves well-meaning users with a jarring experience. I added a buffer: lower the entitlement flag immediately, but keep local artifacts until the next natural boundary. Even when locking someone out, I'd rather close the door quietly.
Billing Retry follows the same philosophy. inBillingRetryPeriod isn't "expired" — it's "Apple is still trying to collect," and if a grace period is set, continuing access is Apple's own recommendation. The inGracePeriod type above exists for exactly this.
Don't burn out on sandbox vs. production differences
Finally, what ate the most time during verification was the sandbox. Sandbox subscriptions renew on absurdly short cycles (a monthly plan renews and expires within minutes), so updates arrives at a speed that never happens in production. That's by design — but the important thing was to not mistake sandbox-specific speed for a production bug. I started logging the environment explicitly to make the distinction easy.
// Determine the runtime environment from Transaction.environment — don't guessextension Transaction { var isSandbox: Bool { environment == .sandbox }}
When I have Antigravity draft the reconcile logic, I tell it up front to "add test cases assuming rapid sandbox renewals." Rather than trusting the generated code as-is, I make pinning my own pitfalls as tests part of the same continuous task — and the same ticket stops coming back.
Entitlement drift shows up not as a dramatic crash but as a quiet misalignment. That's precisely why getting the single point of purchase right isn't enough: rebuild state on every launch, reconcile against server-confirmed facts, and respect grace. This unglamorous back-and-forth is what worked. If you're wrestling with the same "I paid but can't use it" reports, I hope these notes give you a thread to pull on.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.