Subscription Entitlement Mismatch After Purchase (StoreKit 2 Fix)

User paid but your app still shows free, or canceled but still Pro. The server-authoritative StoreKit 2 fix, with App Store Server Notifications V2 and reconciliation.

Fastest fix: stop trusting a local isPro flag. Make your server the single source of truth, fetch entitlement on every app launch and foreground, and drive that server state from two inputs that both must be wired: StoreKit 2’s Transaction.updates listener (started in App.init, not a screen) and App Store Server Notifications V2 (configured for both production and sandbox). Then run a daily reconciliation job that queries the App Store Server API for anyone near expiration before you downgrade them. Everything below is the detail behind those five moves.

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, the mirror image: “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.

These mismatches almost always come from treating Apple’s purchase system as a one-shot event (“they paid, set isPro=true”) instead of a continuous stream of state changes: purchase, renewal, downgrade, refund, expiration, billing retry, grace period. The fix is a server-authoritative state machine that re-evaluates on every relevant event.

Which bucket are you in?

Symptom the user reportsMost likely causeJump to
Paid, charged, still shows free; “already subscribed” on retryEntitlement never written server-side, or a race between purchase and fetchCauses 1, 3, 5
Canceled / expired weeks ago, still shows ProLocal isPro cached forever; no expiration recheckCauses 1, 2
Renewal failed, user cut off the same night despite a valid card on fileGrace period / billing retry not honoredCause 6
Worked before an app update, now whole cohorts show freeRenamed product ID not mappedCause 7
Random users flip free/Pro with no patternWebhook down or returning non-200; device-clock expirationCauses 3, 4

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: SUBSCRIBED, DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, GRACE_PERIOD_EXPIRED, REFUND, REVOKE, DID_CHANGE_RENEWAL_STATUS, DID_CHANGE_RENEWAL_PREF, and more (around 20 notification types as of June 2026, many carrying a subtype such as INITIAL_BUY, RESUBSCRIBE, or BILLING_RECOVERY). If your endpoint is down, returns anything other than HTTP 200, or doesn’t verify and decode the signed JWS payload correctly, your server’s view of the subscription stays stale and Apple eventually stops retrying.

How to spot it: In App Store Connect go to your app, then General > App Information, scroll to App Store Server Notifications, and confirm a Version 2 URL is set for both Production and Sandbox (separate fields — a missing Sandbox URL is the classic reason test purchases never reconcile). Hit your own endpoint with curl and confirm it returns 200. Then grep your server logs for any notification received in the last 24 hours. No recent rows means delivery, not parsing, is the problem.

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

When a renewal payment fails, the subscription does not die instantly. Apple runs a billing retry period (up to 60 days) and, if you have it enabled in App Store Connect, a billing grace period on top. The grace period length is your choice in App Store Connect — 3, 16, or 28 days as of June 2026 (it is not a fixed 16 days). During grace, the previous paid period has technically expired but Apple keeps retrying the card, and Apple’s own status for that subscription is 4 (billing grace period), not expired. If your app strictly checks expiresAt > now, you cut these users off mid-grace and they complain even though they did nothing wrong.

How to spot it: Check whether your code reads the grace-period date. In StoreKit 2 it is RenewalInfo.gracePeriodExpirationDate; in the App Store Server API / notification payload it is gracePeriodExpiresDate. Also check whether you distinguish status 3 (in billing retry, no access) from status 4 (in grace, keep access). If you read neither, you are 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, open your app, go to General > App Information, scroll to App Store Server Notifications, and set a Version 2 URL for both the Production and Sandbox fields. If you only set the Production URL, Apple sends sandbox notifications there too; if you only set Sandbox, production sends nothing. Set both.

Your server handler:

// Express example
app.post("/apple/notifications", async (req, res) => {
  const { signedPayload } = req.body;
  // Verify the JWS signature chain up to the Apple root CA, then decode.
  const decoded = verifyAndDecodeJWS(signedPayload);
  const { notificationType, subtype, data } = decoded;

  switch (notificationType) {
    case "SUBSCRIBED":         // subtype: INITIAL_BUY or RESUBSCRIBE
    case "DID_RENEW":          // includes recovery after a failed payment
      await upsertEntitlement(data.originalTransactionId, "pro", data.expiresDate);
      break;
    case "DID_FAIL_TO_RENEW":
      // subtype GRACE_PERIOD means keep access during grace; otherwise no access.
      await markBillingIssue(data.originalTransactionId, subtype, data.gracePeriodExpiresDate);
      break;
    case "GRACE_PERIOD_EXPIRED": // grace ran out and recovery failed
    case "EXPIRED":
    case "REFUND":
    case "REVOKE":             // Family Sharing / refund revocation
      await revokeEntitlement(data.originalTransactionId);
      break;
    // ... handle the remaining ~20 notification types (DID_CHANGE_RENEWAL_STATUS, etc.)
  }

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

Make every handler idempotent — Apple retries on any non-200, and the same event can arrive more than once.

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 (support complained, or your job below flags them), call the App Store Server API’s Get All Subscription Statuses endpoint to get authoritative current state from Apple, regardless of whether your webhook ever fired:

GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{transactionId}
Authorization: Bearer <ES256-signed JWT from your App Store Connect API key>

As of June 2026 you can pass any transactionId for the customer in the path (it no longer has to be the originalTransactionId). The response returns every subscription with a numeric status: 1 active, 2 expired, 3 in billing retry, 4 in billing grace period, 5 revoked. Treat 1 and 4 as entitled; 2, 3, and 5 as not. For sandbox, point at https://api.storekit-sandbox.itunes.apple.com.

Schedule a daily reconciliation job that picks up users whose expiresAt < now and re-queries Apple before downgrading them — this catches every event your webhook dropped.

How to confirm the fix

  • A sandbox tester can buy and the app reflects Pro within about 5 seconds.
  • A canceled sandbox subscription stops being entitled right after the expiration time.
  • A refunded transaction immediately revokes entitlement on the server.
  • A user in the billing grace period keeps seeing Pro until grace ends (status 4), then loses it.
  • Querying the App Store Server API for a known-good user returns status: 1 and matches your DB row.
  • Your server logs show a 200 response for every notification received in the last 24 hours.
  • The daily reconciliation job runs without finding a meaningful number of stale entitlements.

If it still fails

  1. Pick one affected user, grab any of their transactionId values, and query the App Store Server API directly. Compare the returned status and expiresDate with your DB row. The diff tells you exactly which event you missed.
  2. Check the Apple System Status page for App Store / in-app purchase incidents; sandbox notification delivery is less reliable than production and can lag.
  3. Verify your webhook is reachable from the public internet and returns 200 quickly. A reverse proxy that strips the body, a slow handler that times out, or a non-200 on a duplicate event all make Apple back off retries.
  4. Add a “Refresh Entitlement” support button that calls Get All Subscription Statuses on demand — useful for support-driven recovery without a code change or app update.

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. Xcode’s StoreKit testing lets you trigger a failed renewal and grace period locally so you can verify the status-4 path without waiting on real billing.

FAQ

A user paid but the app still shows free for a few seconds, then corrects itself. Bug or normal? Normal if it self-corrects within a few seconds — that is the race in Cause 5. The notification or Transaction.updates event hasn’t reached your server yet. Make the post-purchase screen poll your entitlement endpoint a couple of times (or optimistically grant from the verified local transaction) instead of doing one fetch 50ms after the sheet dismisses.

How long should I cache entitlement on the client? Short — 60 seconds at most — and always refresh on app foreground and before showing a paywalled screen. The server is the source of truth; the client cache only exists to avoid a network round-trip on every tap.

Do I still need App Store Server Notifications if I already listen to Transaction.updates? Yes. Transaction.updates only fires while a device with that Apple ID is running your app. Server notifications are the only way to learn about refunds, expirations, and billing failures for users who aren’t currently in the app. Wire both; reconcile with the App Store Server API.

Why does a user keep Pro for days after a failed payment? That is the billing grace period working as designed (status 4). You chose its length in App Store Connect — 3, 16, or 28 days as of June 2026 — and Apple keeps retrying the card during that window. Cut access at GRACE_PERIOD_EXPIRED, not on the original expiresDate.

A whole cohort dropped to free right after a release. What happened? Almost certainly Cause 7: you renamed a product and the new entitlement map doesn’t list the old productID. Add every historical product ID to the map and run the reconciliation job to re-grant.

Can I test all of this without spending real money? Yes. Use a sandbox Apple Account plus an Xcode .storekit configuration file. Sandbox renewals are accelerated (a monthly subscription renews in minutes), and Xcode can force grace periods, billing retry, and refunds so you can exercise the full state machine.

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