DIAGNOSE
| Built | shotlingo.com |
|---|---|
| Shipped | 3 iOS apps: QuickBill, Travelized, Breaker |
| Published | 6 open datasets |
| Updated | 2026-08-12 |
This page is a decision tree, not a single answer. StoreKit 2's Transaction.currentEntitlements and a server-side receipt-validation backend — RevenueCat, or your own integration against the App Store Server API — both claim to say whether a customer is currently subscribed, and they are not always reading the same thing. Three sections follow, each a specific, documented mechanic behind a disagreement between the two. Work through them in order: each either explains the gap or rules itself out, and you move to the next.
The short version, stated up front: currentEntitlements is a local, on-device read that excludes some categories of transaction by definition and only updates when the device itself has synced with the App Store. A receipt-validation backend is a server-side record that Apple pushes to independently of whether the app is even installed. Most disagreements are one of those two facts, not a bug in either system.
Every mechanic below is checked against current Apple and RevenueCat documentation, cited inline with the date checked. Where I could not confirm something against a primary source, that is stated rather than filled in.
Not sure which of these is your gap? send me what the two sources show for one subscriber → and I'll tell you which section below to start with.
| Source | What it holds | What it leaves out |
|---|---|---|
Transaction.currentEntitlements | A locally-cached, on-device sequence: one transaction per non-consumable purchase, the latest transaction per auto-renewable subscription whose renewal state is subscribed or inGracePeriod, and the latest per non-renewing subscription. Populated by StoreKit syncing with the App Store, automatically, without a call to sync() in normal operation. | Consumable purchases, always. Any transaction the App Store has refunded or revoked. Any subscription in inBillingRetryPeriod — that state is defined as not entitled to service. Anything from another platform, or another device, that this device's local StoreKit store has not yet synced. |
| Server-side receipt validation (RevenueCat, or the App Store Server API directly) | The App Store Server API "is independent of the app's installation status on the customers' devices," returning a customer's purchase history "regardless of whether the customer installs, removes, or reinstalls the app." Refund and revocation events (REFUND, REVOKE) arrive as server-to-server push notifications the App Store server describes as "near real-time," with no dependency on the app being open. | Nothing structural — this is the more complete record by design. What it can lag on is whatever your own backend does with a notification after receiving it: how quickly your handler processes a webhook, and whether your own status mapping matches Apple's. |
(Apple Developer, "currentEntitlements", checked 2026-08-12. Apple Developer, "App Store Server API", checked 2026-08-12. Apple Developer, "Handling refund notifications", checked 2026-08-12. Apple Developer, "notificationType", checked 2026-08-12.)
currentEntitlements at all#This is the cheapest section to rule out, because it is not a timing problem — it is a definition.
Apple's own documentation for currentEntitlements is explicit about what does not appear in it: "Products that the App Store has refunded or revoked don't appear in the current entitlements. Consumable In-App Purchases also don't appear in the current entitlements" (Apple Developer, "currentEntitlements", checked 2026-08-12).
That means two structurally different sets of purchases will never show up on the device side of a comparison, no matter how fast the sync is: consumables (coins, gems, one-time credit packs), and anything already refunded or revoked. If your backend's "subscribed" or "entitled" flag is derived from a broader transaction history — for instance, a receipt-validation record that still lists a refunded transaction until your own pipeline explicitly nets it out — the two will disagree on that customer even after both sides are fully synced, because they are answering different questions about the same purchase history, not reading the same data at different times.
What to check: pick a subscriber whose disputed transaction is a consumable, or one you know was refunded. If they are the mismatch, this section is your answer, and the fix is in how your backend books refunds and consumables, not in anything StoreKit is doing wrong.
Assume the mismatch is a subscriber who should clearly be either paying or not, and it is neither of the above. This is the next thing to check, because Apple documents the two systems using different vocabulary for the same interruption in payment.
On the device, a subscription's renewal state is one of subscribed, inGracePeriod, expired, inBillingRetryPeriod, or revoked. Apple's own documentation for that enum draws a line: subscribed and inGracePeriod are "entitled to service," while expired, inBillingRetryPeriod, and revoked "aren't entitled to service — unless the customer has other status items that provide entitlement" (Apple Developer, "RenewalState", checked 2026-08-12). currentEntitlements only ever includes a subscription whose renewal state is subscribed or inGracePeriod — a subscription that has fallen into billing retry, but has not yet reached (or was never enrolled in) a grace period, drops out of currentEntitlements immediately.
The App Store Server API's Status field, returned by Get All Subscription Statuses, keeps billing retry and billing grace period as two separate, numbered states as well: 1 active, 2 expired, 3 billing retry, 4 billing grace period, 5 revoked (Apple Developer, "Status", checked 2026-08-12). Apple keeps the same distinction on both sides — the disagreement is not in Apple's model, it is in whatever your backend chooses to do with status 3. A backend that treats "billing retry" as still-effectively-subscribed (a common leniency choice, so a customer whose card briefly fails does not lose access mid-retry) will show that customer as subscribed while the device's own currentEntitlements has already dropped them, and it will keep disagreeing for as long as the retry period lasts, not because either side is stale, but because they are applying different policies to the same documented state.
Whether Billing Grace Period is enabled for your subscription group in App Store Connect changes how long this window is and whether it exists at all, and that setting lives outside both APIs — I could not verify a specific default from the API documentation itself, and Apple's own App Store Connect help is the place to confirm it for a given app.
Still stuck after this section? Have me look at the two records directly →. The last section covers cases where both sides agree on the definition and disagree only on timing.
If neither of the above accounts for the gap, what usually remains is not a disagreement about definitions but about how fast each side heard the news, and from where.
On the device, the live channel for a change that happens outside the app is Transaction.updates: "the asynchronous sequence that emits a transaction when the system creates or updates transactions that occur outside the app or on other devices" (Apple Developer, "Transaction.updates", checked 2026-08-12). That only fires while something is listening, which in practice means the app has to be running, or at minimum have been launched recently enough to have picked up the change — Apple's guidance for the general case is that in regular operation "there's no need to call sync(). StoreKit automatically keeps up to date transaction information and subscription status available to your app," reserving a manual AppStore.sync() call, which prompts the customer to re-authenticate, for a user-initiated "Restore Purchases" action or a case where "a user suspects the app isn't showing all the transactions" (Apple Developer, "sync()", checked 2026-08-12). A device that is offline, or simply has not had the app opened since a refund or a Family Sharing change occurred, is reading a currentEntitlements snapshot from before that event, with no error or staleness flag attached to tell you so.
On the server side, the same class of event — a refund, or a Family Sharing revocation — is pushed independently of the app's state. Apple states plainly that "the App Store server sends near real-time notifications when customers receive refunds for in-app purchases" via the REFUND notification type (Apple Developer, "Handling refund notifications", checked 2026-08-12), and a separate REVOKE notification fires when "an In-App Purchase the customer was entitled to through Family Sharing is no longer available through sharing" (Apple Developer, "notificationType", checked 2026-08-12). Neither depends on whether the affected device is online, or even still has the app installed — which is the same property the App Store Server API states about itself for on-demand lookups (Apple Developer, "App Store Server API", checked 2026-08-12).
RevenueCat's own documentation of its Offline Entitlements feature is a useful, concrete illustration of this exact asymmetry from the other direction: it exists because RevenueCat's SDK "uses StoreKit 2 to determine the current subscriptions" on-device, and to keep working when RevenueCat's own servers are briefly unreachable it caches "a full mapping of products and entitlements" locally — explicitly noting the feature "requires the user to launch the app at least once with our servers online" before that local cache is trustworthy, and that a purchase made on a different platform during an outage "won't carry over" until sync resumes (RevenueCat, "Introducing Offline Entitlements", checked 2026-08-12). Even a backend built specifically to bridge the two propagation paths still has an on-device cache that can be behind the server record, by its own design.
What to check: for the specific subscriber who disagrees, find the timestamp of the refund or revocation on the server side (from the webhook or the App Store Server API), and compare it to the last time that customer's device is known to have launched the app with network access. If the device has not been open since the event, this is your answer, and it self-corrects the next time the app runs online — nothing to fix, unless your app is reading currentEntitlements as the sole source of truth for a decision that has to be current the instant a refund happens, in which case the fix is to trust the backend's push for that decision instead.
For a same-device, same-session gate — unlocking content the instant the app opens, with no network round trip — currentEntitlements is the right source. It is designed to be read that way: fast, local, and correct as of the device's last sync.
For anything that has to be current the moment Apple's servers process an event, independent of whether that specific device is online — access control enforced from your backend, a support ticket about a refund, a cross-platform or cross-device entitlement check — the server-side record is the right source, because it is the one Apple pushes to directly rather than the one a device has to come and get.
If both sides agree on the renewal-state definition and the timestamps line up, and they still disagree, that is not one of the three causes above, and I would not guess at a fourth without a primary source to check it against.
If the numbers that disagree are revenue totals rather than subscriber status — RevenueCat, Mixpanel or Amplitude, and App Store Connect reporting different dollar figures for the same period — that is a different set of causes, with its own decision tree: why your RevenueCat, Mixpanel and App Store Connect revenue numbers don't match.
Send me what the two sources show for one subscriber → and I'll tell you which of the three causes above is yours.
[ 01 ] Get the next one
One article when the next one ships. No spam, unsubscribe anytime.
[ 02 ] Get this done
Send the product URL and what looks wrong. A real reply, not an autoresponder.