◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-06-23Advanced

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.

StoreKit 24subscriptionsiOS27Antigravity377Swift8app-dev54

✦ Premium Article

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 history
struct 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 currentEntitlements only 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.

SourceAnswersWeakness
Transaction.currentEntitlementsValid transactions this device knows aboutLocal-cache dependent; can miss
Product.SubscriptionInfo.StatusRenewal state, grace, expiration reasonAssumes the product is already loaded
App Store Server Notifications V2Server-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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-05-25
One Month Splitting Antigravity's Inline Edit and Agent Mode Across Four Wallpaper Apps
A month of notes from running Antigravity's Inline Edit and Agent Mode across four production wallpaper apps — with real counts, the decision rule I wrote into AGENTS.md, a one-pass dSYM fix, and how credit cost factors in.
▣ App Dev2026-04-09
Antigravity × App Clip, WidgetKit & Live Activities: Designing an iOS Monetization Funnel
Learn how to design an iOS monetization funnel using App Clip, WidgetKit, and Live Activities with Antigravity. From cold user acquisition to paid conversion, covering StoreKit 2 and RevenueCat integration.
▣ App Dev2026-03-18
Building Production iCloud Sync Apps with SwiftUI and CloudKit in Antigravity
Build a production iCloud sync iOS app with SwiftUI and CloudKit in Antigravity. Covers CKRecord design, offline support, and the queryable-index and Development-to-Production schema pitfalls that break release builds.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links