A user emails support: they bought your Pro subscription on an old iPhone last year, just set up a new iPhone, signed in with the same Apple ID, opened your app, tapped Restore Purchases, and saw “Nothing to restore” — yet App Store Connect shows their transaction is still active. Or the same user uninstalled and reinstalled on one device and your app now treats them as free. The transaction record still exists in their Apple Account; your app just isn’t reading it back correctly.
Fastest fix (StoreKit 2, the 80% case): on launch, read Transaction.currentEntitlements silently (no prompt) and grant access from whatever it returns. Behind the visible Restore Purchases button — and only there — call try await AppStore.sync(), then re-read currentEntitlements. currentEntitlements reads a local cache that can come back empty on a fresh install or after a device restore; AppStore.sync() forces StoreKit to re-pull from Apple, but it triggers an Apple Account password prompt, so it must never run on launch.
Restore failures are almost always on the app side, not Apple’s. Three layers — the StoreKit API call, server-side validation, and entitlement persistence — each have characteristic failure modes. Get all three right and restore becomes nearly invisible: silent on launch, with the button only as a manual fallback.
Which bucket are you in?
| Symptom | Most likely cause | Jump to |
|---|---|---|
| Works on first device, fails after reinstall on the same device | Local “isPro” flag wiped on uninstall | Cause 1 / Step 1 |
| Restore returns transactions but server rejects them | Validation routing or shared-secret error (21007, 21003) | Cause 2 / Step 2 |
| Restore works for new buyers, fails for users who switched accounts | Entitlement keyed to app account, not Apple Account | Cause 3 / Step 1 |
| Only older subscribers fail | Renamed product ID not mapped | Cause 4 / Step 3 |
| Migrated to StoreKit 2 and old buyers can’t restore | SK1 receipt vs SK2 JWS mismatch | Cause 5 / Step 4 |
| Family member can’t restore a shared purchase | ownershipType == .familyShared not handled | Cause 6 / Step 4 |
currentEntitlements returns zero on a valid purchase | Local StoreKit cache never synced on this device | Step 5 |
Common causes
Ordered by hit rate.
1. App uses a local “purchased” flag that wipes on reinstall
UserDefaults (or AsyncStorage on React Native, SharedPreferences on Android) gets cleared on uninstall. Your code reads defaults.bool(forKey: "isPro") on launch and gates the feature off because the flag is false. The transaction is still in the user’s Apple Account; you just never re-check it.
How to spot it: search your code for any reference to UserDefaults, @AppStorage, or similar local storage used as the source of truth for purchase state. If gating logic depends on it, you have this bug.
2. Receipt / transaction validation is misconfigured
You’re calling Apple’s verifyReceipt with the wrong shared secret, hitting the production endpoint with a sandbox receipt (returns 21007), or your backend rejects receipts older than X days. Restore returns transactions but your server marks them invalid and the app discards them.
How to spot it: read the server logs at your validation endpoint during a restore. Look for status codes 21002 (malformed receipt), 21003 (authentication failure), 21007 (sandbox receipt sent to the production endpoint), or 21008 (production receipt sent to the sandbox endpoint). Any of these signals a routing or shared-secret issue.
Note (as of June 2026): Apple has deprecated verifyReceipt and App Store Server Notifications V1. They still work and have no announced end-of-life date, but receive no new features. New code should validate against the App Store Server API instead (see Step 2).
3. Entitlement tied to your app account, not the Apple Account
Your user signs into your app with email + password. Their Apple Account purchased Pro, but your account table records the entitlement under the wrong user_id, or under the device’s anonymous user. New device, new anonymous user, no entitlement.
How to spot it: query your database for the affected user’s account rows. Check whether is_pro = true is set on the row they’re currently logged into. If it’s set on a different row, you have an account-linkage bug.
4. Product ID was renamed
Old product com.acme.pro_monthly, replaced this release with com.acme.pro_monthly_v2 (lower price). Your entitlement code only recognizes the new ID. Old subscribers restore the old ID and your code ignores it.
How to spot it: search your code for product-ID string literals. If only current IDs appear, legacy purchases are invisible. Check App Store Connect, then your app, then Subscriptions for the full history of IDs.
5. StoreKit 1 → StoreKit 2 migration broke receipt handling
You moved to Transaction.currentEntitlements (SK2) but purchases made under StoreKit 1 are tied to the bundle receipt. SK2 reads the signed JWS transaction history, not the old receipt blob; if the local transaction cache hasn’t synced on this device, old purchasers come back empty from the SK2 path.
How to spot it: in the affected user’s session, run Transaction.currentEntitlements and, behind a button, AppStore.sync() followed by a re-read. If the entitlement only appears after sync(), the local cache was stale — see Step 5.
6. Family Sharing or Ask to Buy not handled
The original purchaser shares via Family Sharing; the family member’s transaction has ownershipType == .familyShared. Your code checks .purchased only and skips it. On iOS 26.x there is also a known regression where family-shared non-consumables can drop out of currentEntitlements after a restore until AppStore.sync() is called.
How to spot it: in your restore handler, inspect the ownershipType field on each transaction. If you switch on .purchased only and not .familyShared, family-shared users fail.
Information to collect
- The user’s
originalTransactionIDfrom App Store Connect, then Sales, then Transactions. - Your client-side log of the restore call: did it return zero transactions, or some?
- Server-side log at the validation endpoint: status codes and response bodies.
- The set of product IDs your app expects vs the set the user owns.
- Whether the user is on the same Apple Account across devices (Settings, then tap their name at the top).
Shortest path to fix
Step 1: Make entitlements server-authoritative
Stop treating the device as the source of truth. On every app launch and on every successful purchase or restore, send the transaction (the signed JWS in StoreKit 2) to your server, let the server validate it with Apple, and let the server return the current entitlement state. Cache the result locally with a short TTL for offline use only.
// Swift / StoreKit 2: read silently on launch, no prompt
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
await syncToServer(jws: transaction.jsonRepresentation)
}
Server: validate the JWS signature using Apple’s App Store Server Library, then upsert the entitlement keyed by the user’s account.
Step 2: Validate server-side with the App Store Server API
For new code (as of June 2026), validate against the App Store Server API rather than the deprecated verifyReceipt. You authenticate with a JWT signed using ES256 (aud set to appstoreconnect-v1, typically a 5-minute expiry) and look up status by originalTransactionId:
GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
Authorization: Bearer <ES256 JWT>
Use the sandbox host https://api.storekit-sandbox.itunes.apple.com for sandbox transactions. The response is signed JWS data with structured, already-parsed transaction info — no receipt parsing required.
If you’re still on the legacy verifyReceipt path, the rule is: always hit production first, and if it returns 21007, retry against sandbox. Never hardcode one endpoint.
// Node.js: legacy verifyReceipt fallback (deprecated, still functional)
async function verify(receipt) {
const prod = await fetch("https://buy.itunes.apple.com/verifyReceipt", {
method: "POST",
body: JSON.stringify({ "receipt-data": receipt, password: SHARED_SECRET })
});
const data = await prod.json();
if (data.status === 21007) {
const sb = await fetch("https://sandbox.itunes.apple.com/verifyReceipt", { /* same body */ });
return sb.json();
}
return data;
}
Step 3: Map all legacy product IDs to entitlements
Build a mapping table on your server (not in the app):
const ENTITLEMENT_MAP = {
"com.acme.pro_monthly": "pro",
"com.acme.pro_monthly_v2": "pro",
"com.acme.pro_yearly": "pro",
"com.acme.pro_lifetime": "pro_forever",
"com.acme.coins_100": "coins:100", // consumable
};
When validating, look up the productID and apply the entitlement. Old purchases keep working.
Step 4: Handle StoreKit 2 + Family Sharing
for await result in Transaction.currentEntitlements {
guard case .verified(let txn) = result else { continue }
let isOwned = txn.ownershipType == .purchased || txn.ownershipType == .familyShared
if isOwned {
await grantEntitlement(productID: txn.productID)
}
}
Don’t filter on .purchased alone, or every Family Sharing member fails.
Step 5: Split silent launch reads from the interactive Restore button
This is the step most apps get wrong. There are two distinct mechanisms, and they are not interchangeable:
Transaction.currentEntitlementsis silent: run it on every launch to grant access from the local cache. No prompt.AppStore.sync()(StoreKit 2) andSKPaymentQueue.default().restoreCompletedTransactions()(StoreKit 1) prompt for the Apple Account password. Apple’s guidance is to call them only in response to an explicit user action. Never call them on launch.
currentEntitlements can legitimately return zero transactions for a valid, active purchase when StoreKit’s local cache has never synced on this device — common after a fresh install, a device restored from backup, or an Apple Account change. AppStore.sync() forces a re-pull from Apple. Gate it behind your Restore button, and re-read currentEntitlements after it finishes:
func restorePurchases() async {
do {
try await AppStore.sync() // prompts for Apple Account password
} catch {
// User cancelled the prompt, or sync failed; surface a retry, don't crash.
return
}
await refreshEntitlements() // re-read currentEntitlements after sync
}
Note: if the user cancels the password prompt, AppStore.sync() may return without throwing and without actually syncing. Always re-read currentEntitlements afterward and show a clear “still not finding your purchase?” path rather than assuming success.
Step 6: Test with sandbox + StoreKit configuration files
In Xcode 12 and later, add a .storekit configuration file and run it locally to simulate purchases, refunds, and renewals without the real sandbox. Then sandbox-test the full path: buy, uninstall, reinstall, launch, and verify the silent launch read works — then test the Restore button separately to confirm AppStore.sync() recovers an empty cache.
How to confirm the fix
- A sandbox tester can buy on Device A, uninstall, reinstall, and see Pro features on launch without tapping anything (silent
currentEntitlementsread). - When the local cache is empty, tapping Restore Purchases runs
AppStore.sync(), prompts once, and unlocks Pro on re-read. - The same sandbox tester can sign into the same Apple Account on Device B and see Pro features after launch (or after one Restore tap).
- Your server logs show successful validation against Apple for every restore.
- A Family Sharing tester (a separate sandbox account) also sees the entitlement restored.
- Production users who previously complained confirm the fix after the next app update.
FAQ
Why does Transaction.currentEntitlements return nothing even though the purchase is active?
It reads a local cache that StoreKit syncs from Apple. On a fresh install, a backup-restored device, or after an Apple Account change, that sync may not have run, so the stream is empty. Call AppStore.sync() behind your Restore button, then re-read.
Should I call AppStore.sync() on app launch to auto-restore?
No. It prompts for the Apple Account password. Apple’s guidance is to call it only on explicit user action. Use the silent currentEntitlements read for launch and reserve sync() for the Restore button.
Is verifyReceipt dead? Do I have to migrate immediately?
As of June 2026, verifyReceipt and App Store Server Notifications V1 are deprecated but still functional, with no announced end-of-life date. Existing apps keep working; new validation code should use the App Store Server API and ASN V2.
The user is on the same Apple Account but still gets “Nothing to restore” — now what?
Confirm the Apple Account at Settings, then their name at the top — make sure it matches the purchasing account, not just iCloud. Then have them tap Restore (to force AppStore.sync()) and check your server logs for the validation status code. A 21003 points at a shared-secret problem; an empty client response points at a stale cache.
Why does restore work for me in the simulator but fail for a real user?
The simulator and a .storekit config file use a local store, not Apple’s servers, so they never exercise the cache-sync edge case. Always test the full buy → reinstall → restore loop on a device with a real sandbox account.
If it still fails
- Dump the receipt or transaction JWS from the affected user (have them email support) and run it through your validation pipeline locally; the error code or
originalTransactionIdwill pinpoint the issue. - Verify your shared secret in App Store Connect, then Apps, then Subscription, then App-Specific Shared Secret; rotate it if you suspect leakage.
- Check Apple’s System Status page for App Store / receipt-validation outages, especially in sandbox.
- As a workaround, build a “Contact Support to Restore” path: collect the user’s
originalTransactionID, manually grant the entitlement on your server, and document the case.
Prevention
- Treat entitlements as a server-side state machine; the device is a client that queries, never the source of truth.
- Add a CI integration test that exercises buy → reinstall → restore using a StoreKit configuration file on every PR.
- Maintain a
LEGACY_PRODUCTS.mdlisting every old product ID and its current mapping; require updating it whenever you rename a product. - Subscribe to the App Store Server Notifications V2 webhook so your server learns about renewals and refunds without polling.
- Keep Restore Purchases discoverable but non-essential — the silent launch read covers the 95% case, and the button (with its password prompt) is the explicit fallback.