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 23subscriptions2iOS24Antigravity264Swift8app-dev37

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 $10 for lifetime access
View Membership →

Related Articles

App Dev2026-04-01
Antigravity × SwiftData Practical Guide — Migrating from Core Data and Mastering the New Standard
Learn how to implement SwiftData — Apple's modern data persistence framework for iOS 17+ — with Antigravity's AI agents. Covers key differences from Core Data, basic CRUD operations, and a step-by-step migration guide with code examples.
App Dev2026-05-26
Unifying In-App Review Prompts Across 5 Apps with Antigravity Editor — A Few Days of Notes
Notes from a few days spent unifying SKStoreReviewController trigger conditions across five iOS wallpaper apps I run as an indie developer, using Antigravity Editor's multi-file editing to bring the logic onto one shared coordinator.
App Dev2026-04-15
Antigravity × App Store Connect API: Complete Automation Guide for Revenue, ASO & Reviews
A hands-on guide to automating App Store Connect with Antigravity. From JWT authentication to sales report fetching, review sentiment analysis, ASO tracking, and a daily Slack dashboard — everything indie developers need to reclaim development time.
📚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
See all →