A user paid and Pro never turned on. The receipt was valid; the server was fine. The culprit was a name.
RevenueCat auto-generates the entitlement from the app name when you create a project. The code was looking at entitlements["pro"]. Pull every ID that actually existed in the dashboard and the conventions were all over the place:
cyra Pro / cyra_premium / PulseCalm Pro / GutLog Pro / HQLens Pro /
Innra Pro / Sage Pro / Solca Pro / Zone 2 Longevity Coach Pro / ... / proExactly one of those matches "pro". The rest all complete the purchase, produce a valid receipt, get a 200 from the server — and the app keeps deciding you're on free.
The real trap is that nothing errors
entitlements["pro"] is just nil. No exception, no log, no way in the code to tell it apart from correct behavior. It's the kind of bug you only learn about when a user emails you. Don't rename in the dashboard and, from that moment, you quietly leak revenue.
Auditing the whole fleet, the trap turned out to be three layers deep.
① Even a correct ID can fail. One app had the exact right ID, but the products attached to it — monthly/yearly/lifetime — all belonged to the Test Store. Don't just check the ID; check that the Associated products belong to the real App Store app. And that entitlement's name carried a different app's bundle ID, so the name alone didn't even tell you which app it was.
② A project can have multiple entitlements. That app had both a neglected one (Test Store products only) and a working one (pro, real products), and the code OR'd both IDs, so it was actually fine. Judge from one and conclude "this app is broken" and you're wrong.
③ I got the risk criterion wrong at first — this is the important one. I narrowed the risk set to the intersection of "single ID" AND "no refresh at runtime." Wrong. Whether it refreshes only affects how fast the correct ID propagates. If the ID is wrong, no amount of polling ever helps — it's free forever. Two apps with a perfectly good refresh delegate were about to keep polling the wrong key. There's only one criterion: does one ID carry everything?
The fix: named ID first, active-entitlement fallback
private extension CustomerInfo {
func hasActiveEntitlement(_ id: String) -> Bool {
if entitlements[id]?.isActive == true { return true }
return !entitlements.active.isEmpty // absorbs whatever the dashboard name is
}
}On Android I split the decision into a pure object and unit-tested it:
ProEntitlement.isActive(exactIdActive, activeEntitlementIds)Scope: fallback on 18 apps / 8 that were already safe via a multi-ID OR / 1 multi-tier that decides directly from StoreKit2 currentEntitlements.
⚠️ The fallback is only safe on single-tier apps. For multi-tier (Basic/Pro), "any active entitlement means Pro" is plainly wrong.
Delete the duplicate entitlement?
Not deleting is safer. Deletion is irreversible and all you gain is dashboard readability. With the fallback in place, any active name makes the app recognize Pro, so a leftover entitlement is actually a safety net. If you must delete, check Associated products first — if even one real product is attached and it isn't on another entitlement, that product's buyers match nothing anywhere. If confusion is the problem, append (unused) to the Display Name instead of deleting.
What's left, honestly
- All 18 fixes touched only iOS files. On account-sharing apps I created a day where the same purchase behaved differently per platform. Android was fixed the next day. Nobody noticed the commits grazed only the iOS path until then.
- The iOS fix has no test — it's a
CustomerInfoextension, so I wrote it in a hard-to-test shape. Only Android got done properly. - Fixing the dashboard doesn't fix running apps. Most refresh only on
configure()once and right after purchase/restore. You have to tell users to "fully quit and relaunch, or restore purchases."
The blast radius reached revenue analysis too. You mustn't read the "paid but Pro not enabled" window's churn as user attrition — that story is in my top-impression app earned zero.
Is your app's entitlement lookup key exactly the name in the dashboard? One character off and the user pays and gets nothing — and you won't even see it in the logs.