I’ve shipped StoreKit in 12 iOS apps, eight of them on a subscription model. Every new project surfaces an edge case I missed on the last one. Here are the five situations that silently bleed revenue.
1. Skip server-side receipt validation and the leak is inevitable
Apple’s client-side receipt check is fine for showing UI. For accounting, never trust the client.
The reason is simple: a user opens your app on a jailbroken device, injects a fake receipt, Transaction.currentEntitlements reports “valid” on the client, the user gets premium access, and not a cent ever reaches Apple.
Fix: on every entitlement check, forward the transaction to your backend and have the backend call Apple’s verifyReceipt endpoint server-to-server. That adds one extra network hop, but the revenue layer is now 100% yours. In StoreKit 2 you verify via JWSTransaction.jwsRepresentation.
In Parademi’s first three months I skipped this. Reports showed users with premium access, yet the matching line never appeared in App Store Connect. A 4 to 5% leak. Once backend validation was in place, it went to zero.
2. The subscription status updates handler has to be always on
When a user is added through Family Sharing, gets a refund, or changes their subscription tier, those changes happen whether your app is running or not. Apple pushes a notification and your backend has to be ready for it.
In StoreKit 2 you subscribe to the Transaction.updates async stream as soon as the app launches. Inside a Task.detached you for await on the stream and handle each new transaction.
If you don’t open this in AppDelegate or your @main struct, you miss the change until the next cold launch. In the meantime you’re showing the wrong entitlement.
3. Grace period and billing retry: protect the user without cutting them off
When a subscription renews, the charge can fail (expired card, over the limit). Apple’s default behaviour is a 3 to 16 day billing retry period. The user stays in premium during that window, but most apps never handle it in code.
Look at Product.SubscriptionInfo.renewalState:
.subscribed: active, nothing to do.expired: over, you can turn premium off.inBillingRetryPeriod: payment retrying, keep showing premium.inGracePeriod: in grace period, keep showing premium.revoked: refunded, turn premium off immediately
Mishandling these states loses you users. There’s no reason to scream “your premium expired” at someone whose charge failed because of a card expiry date.
4. Introductory offers are first-timers only
An introductory offer (“first month free”, “50% off for three months”) is not per user, it’s once per Apple ID, for life. If your client doesn’t gate this correctly, you’ll show the offer to someone who doesn’t qualify, the purchase will go through, and Apple will reject it at the StoreKit layer, failing the checkout.
Fix: for every product check Product.SubscriptionInfo.isEligibleForIntroOffer. If it returns false, hide the intro offer and show the standard price.
5. The refund request handler (quiet but important)
One of the nicer StoreKit 2 additions: users can request a refund in-app via Transaction.beginRefundRequest. No more emailing Apple.
But when the refund is approved you need to know about it so you can shut off premium. Listening on the Transaction.updates stream, check each transaction’s revocationReason. If it isn’t None, the refund went through, and you need to tell your backend and revoke premium.
If you keep giving premium to a refunded user, Apple tracks the abuse and drops your app’s rating. Over time it hurts your App Store rankings.
Have sandbox testers ready
You have to test all of this before pushing to production. Create sandbox tester accounts in App Store Connect and sign into your device with one. In sandbox, subscriptions renew in hours instead of months, so you can exercise renewal, retry, and grace period in an afternoon.
I keep 3 to 4 sandbox testers per new project, across different country codes and tiers. The full subscription flow gets exercised before anything ships.
Takeaway
StoreKit 2 really is a much cleaner API than the original. But “cleaner” does not mean “all solved”, there are still edge cases that move the revenue needle. Test them, put monitoring in place, and review them again on every release.