In-App Purchase Not Restoring Correctly Across Devices

A returning user taps "Restore Purchases" and gets nothing, even though they bought the product.

A user emails support saying they bought your Pro subscription on iPhone last year, just got a new iPhone, signed in with the same Apple ID, opened your app, tapped Restore Purchases, and saw “Nothing to restore” — yet your App Store Connect Sales report shows their transaction is active. Or worse: the user uninstalled and reinstalled on the same device and now your app thinks they’re a free user. The receipt is sitting in their Apple ID; your app just isn’t reading it correctly.

Restore failures are almost always on the app side, not Apple’s. Three layers — StoreKit API call, receipt validation, and entitlement persistence — each have characteristic failure modes. Get all three right and restore becomes invisible to the user (it should run automatically on first launch, not behind a button).

Common causes

Ordered by hit rate.

1. App uses a local “purchased” flag that wipes on reinstall

UserDefaults (or AsyncStorage on React Native, SharedPreferences clone) gets cleared on uninstall. Your code reads defaults.bool(forKey: "isPro") on launch and gates the feature off because the flag is false. The receipt in Bundle.main.appStoreReceiptURL is intact, but you never check it.

How to spot it: Search your code for any reference to UserDefaults, @AppStorage, or similar local storage as the source of truth for purchase state. If gating logic depends on it, you have this bug.

2. Receipt validation is misconfigured

You’re calling Apple’s verifyReceipt endpoint 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 says “invalid” and the app discards them.

How to spot it: Server logs at the validation endpoint when a restore happens. Look for status codes 21002 (malformed receipt), 21003 (auth failure), 21007 (sandbox receipt on prod endpoint), 21008 (prod receipt on sandbox endpoint). Any of these signal a routing or secret issue.

3. Entitlement tied to your app account, not Apple ID

Your user signs into your app with email + password. Their Apple ID 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: Database query: for the affected user, find all their 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 → Subscriptions for the full history of IDs.

5. StoreKit 1 → StoreKit 2 migration broke receipt handling

You moved to Transaction.currentEntitlements (SK2) but receipts from purchases made under StoreKit 1 are still in the bundle. SK2’s API doesn’t read those receipts; it reads the JWS payload. Old purchasers can’t restore via the SK2 code path.

How to spot it: Run both Transaction.currentEntitlements and SKPaymentQueue.default().restoreCompletedTransactions() in the affected user’s session. If only SK1 returns the entitlement, you’re missing the SK2-side equivalent or need to refresh the receipt.

6. Family Sharing or Ask to Buy not handled

The original purchaser shares via Family Sharing; the family member’s restore returns the transaction with originalTransaction.ownershipType == .familyShared. Your code checks .purchased only and skips it.

How to spot it: In your restore handler, inspect the ownershipType field on each transaction. If you switch only on .purchased and not .familyShared, family-shared users fail.

Information to collect

  • The user’s originalTransactionID from App Store Connect → Sales → Transactions.
  • Your client-side log of the restore call: did it return zero transactions or some?
  • Server-side log at receipt-validation endpoint: status codes, response bodies.
  • The set of product IDs your app expects vs the set the user owns.
  • Whether the user is on the same Apple ID across devices (Settings → tap their name).

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 / restore, send the receipt or transaction JWS to your server, let the server validate with Apple, and let the server return the current entitlement state. Cache the result locally with a short TTL for offline use.

// Swift / StoreKit 2 example
for await result in Transaction.currentEntitlements {
    guard case .verified(let transaction) = result else { continue }
    await syncToServer(transaction: transaction)
}

Server: validate the JWS signature using Apple’s App Store Server Library, upsert the entitlement keyed by user account.

Step 2: Fix validation routing (sandbox vs production)

Apple’s pre-Server-API pattern: always hit production first. If it returns 21007, retry against sandbox. Don’t hardcode endpoints.

// Node.js example
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;
}

For production today, prefer the App Store Server API over verifyReceipt — it’s the modern, signed endpoint.

Step 3: Map all legacy product IDs to entitlements

Build a mapping table in your server (not 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.

Step 5: Auto-restore on launch, not just behind a button

@MainActor
class StoreKitManager: ObservableObject {
    init() {
        Task {
            await refreshEntitlements()  // run on every launch
        }
    }

    func refreshEntitlements() async {
        for await result in Transaction.currentEntitlements {
            // ... sync to server, update local state
        }
    }
}

Users shouldn’t have to find a settings menu to be recognized as paying.

Step 6: Test with sandbox + StoreKit configuration files

In Xcode 12+, add a .storekit configuration file. Use it locally to simulate purchases, refunds, and renewals without involving the real sandbox. Then sandbox-test the full path: buy → uninstall → reinstall → launch → verify auto-restore worked.

How to confirm the fix

  • A sandbox tester can buy on Device A, uninstall, reinstall, and see Pro features on launch without tapping a button.
  • The same sandbox tester can sign into the same Apple ID on Device B and see Pro features immediately.
  • Your server logs show successful validation against Apple for every restore.
  • Family Sharing testers (a separate sandbox account) also see entitlements restored.
  • Production users who previously complained confirm the fix after the next app update.

If it still fails

  1. Dump the receipt blob from the affected user (have them email support) and run it through your validation pipeline locally; the error code will pinpoint the issue.
  2. Verify your shared secret in App Store Connect → Apps → Subscription → App-Specific Shared Secret; rotate if you suspect leakage.
  3. Check Apple System Status for receipt-validation outages, especially in sandbox.
  4. As a workaround, build a “Contact Support to Restore” path: collect the user’s originalTransactionID, manually grant entitlement on server, 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 every PR.
  • Maintain a LEGACY_PRODUCTS.md listing every old product ID and its current mapping; require updating it when renaming products.
  • Subscribe to the App Store Server Notifications V2 webhook so your server learns about renewals and refunds without polling.
  • Make Restore Purchases discoverable but non-essential — automatic restore on launch covers the 95% case.

Tags: #Troubleshooting #App Store #App review #IAP