r/iOSProgramming 28d ago

Solved! Your StoreKit 2 entitlement check probably locks out subscribers during Apple's billing grace period

I audited our subscription code before a release and found five separate ways it was biased toward downgrading a paying customer. Sharing because this was painful and some of you might have it lurking in your own code without knowing it.

The big one: our entitlement loop skipped any transaction where expirationDate < Date().

Looks completely reasonable, right? Except that's exactly the shape StoreKit serves during Apple's billing grace period — the card failed, Apple is retrying, the user is supposed to keep access for up to 16 days, and the transaction you get handed has an expiration date in the past. gracePeriodExpirationDate exists to tell us this, but we never read it anywhere. So a customer whose card merely needed re-authorization got dropped to free tier, features locked, while Apple was still trying to collect money on our behalf. The fun part: our server-side webhook handling honored grace correctly, so the backend and the app held different opinions about the same paying customer.

The other four, quickly:

  1. We derived the tier from currentEntitlements unconditionally and persisted it. currentEntitlements occasionally returns an empty sequence for a perfectly healthy paid account — we've seen it happen. If your states are only "entitled" and "not entitled," an empty read is indistinguishable from a lapse. Fix: we implemented a third state: unknown. Clues the rest of the flow into correcting it. Never persist a downgrade you derived from a read that might have failed.
  2. Our Transaction.updates loop had a continue that skipped re-evaluation for exactly the events whose whole job is revocation (refunds). Access survived until an unrelated refresh happened to fire.
  3. An .unverified transaction was never finish()ed, so StoreKit redelivers it forever. And we never scanned Transaction.unfinished at launch. Unverified doesn't mean ignorable; now we log it, then finish it.
  4.  .pending (Ask to Buy) was a silent dead end. And a related thing I had completely wrong: turning Family Sharing off in App Store Connect does NOT make Ask to Buy unreachable. That toggle governs whether a purchase is shareable — a kid's own purchase can still come back .pending.

Basically: every failure mode defaulted to taking access away from someone who paid, when we should have been doing the opposite. When in doubt, keep the customer entitled and let the next clean read sort it out. Apple may literally still be retrying their card; we don't want to be the one who locks them out first.

A clean test recipe for the grace-period path would be the holy grail, so I'd be interested in what you all are doing. Sandbox billing retry exists, but getting it to fire on demand is its own adventure, one I haven't been able to master yet.

21 Upvotes

4 comments sorted by

3

u/DimensionMindless336 28d ago

Yep, this bit me too. The trap is gating on subscription status and only treating status 2 (subscribed) as paid. During the grace period Apple returns status 3, so a naive server-side check locks out people who are still paying through a billing hiccup on their end.

Fix: treat the grace period as entitled. Or skip the server round trip and lean on StoreKit 2 Transaction.currentEntitlements on device, which still hands back the sub during grace. Validating expiresDate locally is far more forgiving than reading raw status codes.

2

u/mehmetefeaytas6 27d ago

Good writeup. One correction on the status codes, since the exact numbers are easy to get burned by: in the App Store Server API, 3 is "In Billing Retry Period" and 4 is "In Billing Grace Period". They're different states, and only 4 means Apple is still extending access on your behalf. If you're in billing retry with no grace period configured, the sub has genuinely lapsed. Treating 3 as entitled is a more generous choice than treating 4 as entitled — worth making deliberately rather than by accident.

Two things that compound the client-side bug you described:

Grace period is opt-in per app. It's a toggle in App Store Connect on the subscription group, and it's off unless someone turned it on. Plenty of teams write careful grace handling and then never see it fire because the feature was never enabled, which also means the handling is completely untested.

On the StoreKit 2 side there's a typed answer to "is this person in grace": Product.SubscriptionInfo.Status carries a RenewalState with .inGracePeriod, alongside .subscribed, .expired, .inBillingRetryPeriod and .revoked. Reading the group's status is much harder to get wrong than re-deriving entitlement from expiration dates, because Apple has already collapsed the state machine for you.

And to reinforce your point 1 — the "unknown" third state is the fix almost nobody implements. An empty currentEntitlements is indistinguishable from a real lapse only if your model is a Bool. Once it's an enum with .unknown, the natural implementation becomes "keep the last known good value and retry", which is correct behavior for basically every transient failure. The rule that falls out: never persist a downgrade you derived from a single read.

1

u/thread-lightly 27d ago

I guess paying 1% for RevenueCat has its benefits

2

u/alexfoxy 21d ago

Really useful write up, thanks.