Subscription / Entitlement Mismatch After Purchase

The user paid for a subscription but your app still shows them as free, or vice versa after cancellation.

A user emails support: “I just paid $9.99 for Pro, the App Store charged me, but the app still shows me as a free user. I tried to upgrade again and it says I’m already subscribed.” Or worse, a different user reports “I canceled my Pro plan three weeks ago — and got charged again last night, but the app correctly shows me as free.” The state your app thinks the user is in doesn’t match what Apple’s purchase system thinks. Money flows one way; access flows the other.

Subscription entitlement mismatches are almost always caused by treating Apple’s purchase system as a one-shot event (“they paid, set isPro=true”) rather than as a continuous stream of state changes (purchase, renewal, downgrade, refund, expiration, grace period). The fix is a server-authoritative state machine that re-evaluates on every relevant event.

Common causes

Ordered by hit rate.

1. Local “isPro” boolean cached forever

You set UserDefaults.set(true, forKey: "isPro") after a successful purchase and never re-check. The user cancels, the subscription expires three weeks later, but the flag is still true. They keep using Pro until they reinstall.

How to spot it: Search your code for any local boolean flag that maps to subscription state. If reads happen without re-checking against StoreKit or your server, this is the bug.

2. Transaction.updates listener not running

In StoreKit 2, Transaction.updates delivers renewal, expiration, and refund events while your app is in the foreground or relaunched. If you don’t subscribe to it on app launch, you miss events.

How to spot it: Search for for await result in Transaction.updates. If it’s only called in one place that isn’t init or applicationDidFinishLaunching, you’re missing events.

3. App Store Server Notifications V2 not implemented or webhook broken

Apple sends server-to-server notifications for every event (DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, REFUND, DID_CHANGE_RENEWAL_STATUS). If your endpoint is down, returns 500, or doesn’t process the JWS payload correctly, your server’s view of the subscription stays stale.

How to spot it: Check the App Store Connect → App Information → App Store Server Notifications URL configured for production and sandbox. Test it with curl. Check your server logs for receipt of recent events.

4. Time-zone or expiration logic compares server time to device time

You compute isExpired = expiresAt < Date() on the device. If the device clock is wrong (jet lag, manual change, broken NTP), expiration triggers prematurely or never.

How to spot it: Search code for Date() or new Date() used in expiration comparison. If you’re not using a server-fetched current time, you have this bug.

5. Race condition between purchase complete and entitlement fetch

The purchase sheet dismisses with success. Your app calls “fetch entitlement” 50ms later. Your server hasn’t yet received Apple’s notification, so it returns “not subscribed.” App shows free; user is confused.

How to spot it: Add detailed logging around purchase completion + entitlement fetch. If the entitlement fetch returns “not subscribed” within seconds of a successful purchase, you have this race.

6. Grace period and billing retry not handled

Apple gives 16-day grace periods for failed renewals. During grace, the user has paid for a previous period that’s expired, but Apple is retrying their card. If your app strictly checks expiresAt > now, you cut them off during grace and they complain.

How to spot it: Check whether your code reads gracePeriodExpiresDate (StoreKit 2: RenewalInfo.gracePeriodExpirationDate). If not, you’re not honoring grace periods.

7. Different product ID mapping per release

You renamed pro_monthly to pro_monthly_v2 in this release. Users with the old subscription have transactions with productID = pro_monthly. Your new code only recognizes pro_monthly_v2. They become “free” overnight.

How to spot it: Audit your entitlement-mapping code. If it doesn’t include every historical product ID, old subscribers break.

Information to collect

  • The user’s originalTransactionID from App Store Connect → Sales → Transactions.
  • Your server log of all events received for that user’s subscription.
  • Client log of every entitlement check + state change.
  • The user’s device clock at the time of the issue (if available).
  • Your current set of supported product IDs and the entitlement map.

Shortest path to fix

Step 1: Make entitlement server-authoritative

Stop using local booleans as the source of truth. On every relevant moment (app launch, foreground, entitlement-sensitive screen), fetch the current entitlement from your server. Cache for 60 seconds max.

@MainActor
class EntitlementManager: ObservableObject {
    @Published var isPro = false

    func refresh() async {
        let result = try await server.fetchEntitlement(userID: currentUser.id)
        isPro = result.tier == .pro
    }
}

// In your scene:
.task { await entitlement.refresh() }
.onChange(of: scenePhase) { newPhase in
    if newPhase == .active { Task { await entitlement.refresh() } }
}

Step 2: Subscribe to Transaction.updates on launch

@main
struct AcmeApp: App {
    init() {
        Task {
            for await result in Transaction.updates {
                guard case .verified(let txn) = result else { continue }
                await syncToServer(transaction: txn)
                await txn.finish()
            }
        }
    }
}

The loop must outlive any single screen. Place it in App init or a singleton.

Step 3: Implement App Store Server Notifications V2

In App Store Connect → App Information → App Store Server Notifications, set the V2 URL for both production and sandbox endpoints.

Your server handler:

// Express example
app.post("/apple/notifications", async (req, res) => {
  const { signedPayload } = req.body;
  const decoded = decodeJWS(signedPayload);  // verify with Apple root cert
  const { notificationType, data } = decoded;

  switch (notificationType) {
    case "SUBSCRIBED":
    case "DID_RENEW":
      await upsertEntitlement(data.originalTransactionId, "pro", data.expiresDate);
      break;
    case "EXPIRED":
    case "REFUND":
      await revokeEntitlement(data.originalTransactionId);
      break;
    case "DID_FAIL_TO_RENEW":
      await markGracePeriod(data.originalTransactionId, data.gracePeriodExpiresDate);
      break;
    // ... handle all 15+ notification types
  }

  res.status(200).send();
});

Make every handler idempotent — Apple may retry.

Step 4: Honor grace periods

let entitlement: String?
if let gracePeriodEnd = renewalInfo.gracePeriodExpirationDate,
   gracePeriodEnd > Date() {
    entitlement = "pro"  // still entitled during grace
} else if let expires = transaction.expirationDate, expires > Date() {
    entitlement = "pro"
} else {
    entitlement = nil
}

Same logic on the server.

Step 5: Map all historical product IDs

Server-side mapping table:

const ENTITLEMENT_MAP = {
  "com.acme.pro_monthly": "pro",
  "com.acme.pro_monthly_v2": "pro",
  "com.acme.pro_monthly_v3_2026": "pro",
  "com.acme.pro_yearly": "pro",
  "com.acme.lifetime": "pro_forever",
};

function entitlementFor(productID) {
  return ENTITLEMENT_MAP[productID] ?? null;
}

Old subscribers stay entitled even when you rename products.

Step 6: Reconcile via App Store Server API for known stragglers

For users whose state is suspicious (e.g., support complained), call the App Store Server API /inApps/v1/subscriptions/{transactionId} to get authoritative current state from Apple, regardless of whether your webhook fired.

Schedule a daily reconciliation job that picks up users whose expiresAt < now and re-queries Apple before downgrading them.

How to confirm the fix

  • A sandbox tester can buy → the app reflects Pro within 5 seconds.
  • A canceled sandbox subscription stops being entitled after the expiration time.
  • A refunded transaction immediately revokes entitlement on the server.
  • A user in grace period continues to see Pro until the grace period ends.
  • Your server logs show successful processing of every notification type within the last 24 hours.
  • Reconciliation job runs daily without finding many stale entitlements.

If it still fails

  1. Pick one affected user, fetch their originalTransactionId, and query the App Store Server API directly. Compare with your DB. The diff tells you which event you missed.
  2. Check Apple System Status for notification delivery issues; sandbox notifications are less reliable than production.
  3. Verify your webhook is reachable from the public internet (Apple’s IPs only) and not behind a VPN or firewall.
  4. Add a “Refresh Entitlement” support button that calls the App Store Server API on demand — useful for support-driven recovery without code changes.

Prevention

  • Treat entitlement as a server-owned, time-bounded grant with explicit expiration — never a device flag.
  • Log every notification event with originalTransactionId and a correlation ID for traceability.
  • Run a daily reconciliation job using the App Store Server API for any user whose state is approaching expiration.
  • Add a “Subscription Debug” screen for internal users / support that shows server state, last notification received, and Apple’s current view.
  • Add a CI integration test that exercises the full lifecycle: buy → renew → cancel → expire → refund, using a .storekit configuration file.

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