StoreKit 2 for iOS subscriptions: transaction verification, status polling, and what changed
StoreKit 2 replaces delegate callbacks and binary receipt blobs with async/await APIs and JWS-signed transactions. This guide covers what changed, how to verify transactions correctly, and how the subscription status model helps you detect churn signals earlier.
StoreKit 2, introduced at WWDC 2021, was Apple's most significant rewrite of the in-app purchase framework since its debut. For subscription-based apps the shift isn't merely syntactic — the new async/await API, cryptographic JWS transaction verification, and subscription status polling fundamentally change how you build reliable billing logic. Yet many apps still run original StoreKit, either because the migration feels daunting or because the business case wasn't clear.
This guide explains the core changes, walks through the verification model that matters most for subscriptions, and helps you decide whether migration is worth it for your app now.
What actually changed between StoreKit 1 and StoreKit 2
The original StoreKit API, built around SKPaymentQueue and SKPaymentTransactionObserver, was designed in an era before Swift concurrency existed. Transactions arrived via delegate callbacks, receipt validation required server-side decoding of a binary blob, and tracking subscription state meant juggling multiple observer methods in the background.
StoreKit 2 replaces this with a fundamentally different model:
- Async/await APIs —
Product.purchase(),Transaction.currentEntitlements, andTransaction.updatesall return or yield values you can await directly in Swift concurrency. - JWS-signed transactions — Every transaction payload is a JSON Web Signature you can verify locally without a round-trip to the deprecated
/verifyReceiptendpoint. Product.SubscriptionInfo.Status— A direct API for querying current subscription state, renewal intent, and expiration reason in one call.AppTransaction— A signed record of the app download, useful for distinguishing App Store installs from TestFlight builds.
The table below summarises the key differences between the two frameworks:
| Capability | Original StoreKit | StoreKit 2 |
|---|---|---|
| Purchase flow | SKPaymentQueue.add(_:) + delegate callbacks |
await product.purchase() returning a typed result enum |
| Transaction receipt | Binary PKCS#7 blob, requires server-side decoding | JWS string, verifiable on-device or server-side |
| Subscription status | Inferred by parsing receipt expiry fields manually | product.subscription?.status returns a typed array |
| Transaction history | SKReceiptRefreshRequest, full receipt only |
Transaction.all async sequence, granular per-product |
| Entitlement check | Parse receipt, compare expiry timestamps manually | Transaction.currentEntitlements async sequence |
| Server validation endpoint | /verifyReceipt (deprecated for new apps) |
App Store Server API returning JWS responses |
| Grace period detection | Check is_in_billing_retry_period field in receipt |
state == .inGracePeriod from SubscriptionInfo.Status |
Transaction verification: the part most teams get wrong
Under original StoreKit, many apps sent the raw receipt to Apple's /verifyReceipt endpoint and trusted the response. This works — but it adds latency, requires server infrastructure, and relies on an endpoint Apple has signalled it intends to phase out for new integrations.
StoreKit 2 ships JWS-encoded transactions. Each transaction is a compact dot-separated string: header.payload.signature. You can verify the signature using Apple's root certificate (AppleRootCA-G3) without any network call, or pass the JWS string directly to your backend and verify it there using standard JWT libraries.
For most subscription apps the recommended pattern is:
- On-device verification using
Transaction.currentEntitlements— StoreKit 2 automatically verifies the JWS signature before surfacing a transaction in this sequence. If verification fails, it returns aVerificationResult.unverified(...)case instead, which you must treat as a failed purchase. - Server-side persistence — pass the verified JWS payload to your backend, decode the base64url payload, verify the certificate chain against Apple's root CA, and store subscription state server-side so entitlement survives app reinstalls and cross-device scenarios.
A common mistake is skipping the VerificationResult check entirely:
// Wrong: ignores verification failures
for await result in Transaction.currentEntitlements {
let tx = result.unsafePayloadValue // never do this
grantAccess(to: tx.productID)
}
// Correct: handle unverified case explicitly
for await verificationResult in Transaction.currentEntitlements {
switch verificationResult {
case .verified(let transaction):
await grantEntitlement(for: transaction.productID)
case .unverified(_, let error):
// Log error; do not grant access
logger.warning("Unverified transaction: \(error)")
}
}
Never call unsafePayloadValue on production transactions. Every transaction in StoreKit 2 arrives as a VerificationResult. Bypassing the verification check means granting access without validating Apple's cryptographic signature — precisely the gap that receipt spoofing attacks exploit. Treat .unverified the same way you'd treat a declined charge.
Subscription status polling and the renewal intent model
One of StoreKit 2's most useful additions for subscription apps is Product.SubscriptionInfo.Status. Calling product.subscription?.status returns an array of status objects — one per subscription group — each containing three key fields:
state— an enum:.subscribed,.expired,.inBillingRetryPeriod,.inGracePeriod, or.revokedrenewalInfo— aVerificationResult<Product.SubscriptionInfo.RenewalInfo>containing thewillAutoRenewflag, theexpirationReason, and thegracePeriodExpirationDatetransaction— the most recent verified transaction for that subscription
This matters for subscription UX in ways the old receipt model made awkward. With StoreKit 2 you can surface each state as a distinct product experience:
- Grace period banner — if
state == .inGracePeriod, Apple is still trying to charge the user. Show a non-blocking notice rather than immediately locking them out; locking them out during grace period increases reported churn without reducing actual billing failure. - Billing retry awareness —
state == .inBillingRetryPeriodmeans the grace period expired and Apple is still retrying. RevenueCat reports have shown that a meaningful fraction of involuntary churn is recovered during this window; logging these users to a dedicated CRM segment and targeting them with a price-reduction promotional offer is one recovery lever. - Cancellation intent detection —
renewalInfo.willAutoRenew == falsemeans the subscriber cancelled but hasn't expired yet. This is your window to surface a retention offer before the subscription lapses. Combined with the promotional offers API covered in our promotional offers guide, you can automate this in-app without waiting for App Store Server Notifications.
Polling timing matters. Apple recommends checking entitlements at app launch and whenever the app returns to the foreground, using Transaction.updates as an async listener for real-time changes mid-session. Do not implement a background timer poll — this wastes battery and is redundant given the push-based updates sequence that StoreKit 2 provides.
Migration path: is now the right time?
Apple has deprecated /verifyReceipt for apps adopting StoreKit 2, but existing apps using the endpoint continue to receive responses. There is no published hard sunset date as of mid-2026. The direction is clear though: Apple's own documentation, WWDC sessions from 2021 through 2024, and the App Store Server API — which returns JWS-native responses — all point to StoreKit 2 as the long-term integration path.
Practical migration considerations for subscription apps:
Minimum deployment target. StoreKit 2 requires iOS 15+. If your app still supports iOS 13 or 14, you need conditional code paths, which doubles the IAP surface area to maintain. Purchase SDKs like RevenueCat and Adapty handle this by running StoreKit 2 on iOS 15+ and falling back to original StoreKit automatically, making the iOS version floor less of a blocker if you route purchases through one of those SDKs.
Existing subscriber history. Transactions made under original StoreKit are visible in Transaction.all and Transaction.currentEntitlements once your app adopts StoreKit 2 — Apple backfills historical transactions. You don't need a migration gate for existing subscriber history or entitlement grants.
Server-side receipt logic. If your backend currently validates receipts via /verifyReceipt, you'll need to add JWS decoding. Apple's App Store Server API provides a getTransactionHistory endpoint that returns JWS payloads for the same transaction history, making a gradual server-side migration possible without a flag day. The App Store Connect API authentication you may have already set up (covered in our JWT authentication walkthrough) uses the same key for App Store Server API calls.
Third-party SDKs. If you use RevenueCat, Adapty, or a similar subscription management SDK, much of this migration is handled for you — check each SDK's documentation for the initialiser argument or configuration flag that enables StoreKit 2 mode. The trade-off is that you lose some control over the raw VerificationResult handling in exchange for reduced boilerplate.
If your app targets iOS 15+ and handles purchases in-house, migrating to StoreKit 2 now removes a dependency on a deprecated endpoint, gives you cleaner subscription state semantics, and eliminates the server round-trip for basic entitlement checks — with no meaningful compatibility risk for existing subscribers.
What this means for pricing and entitlement architecture
StoreKit 2 doesn't change how you structure subscription tiers, trial offers, or promotional offers — those remain App Store Connect configuration, covered in our guides on introductory offers and promotional offers. What it changes is how reliably you detect subscription state at runtime, and how much logic you can move client-side without sacrificing security.
For developers managing pricing across regions, the practical implication is that Transaction.currentEntitlements is the authoritative source of what a customer currently holds, regardless of which currency or territory the subscription was purchased in. Entitlement logic is territory-agnostic; territory-level price management lives entirely in App Store Connect. See our pricing tools for managing per-territory price tiers without touching your billing code.
One area where StoreKit 2's status model specifically helps pricing strategy: detecting .inBillingRetryPeriod lets you identify users at elevated churn risk in specific markets before the subscription formally lapses. Phiture's research on subscription retention suggests that in-app messaging during the retry window — rather than waiting for the expiration — meaningfully improves recovery rates, particularly in markets where alternative payment methods are less common and bank declines are more frequent.
Sources and further reading
- Apple Developer Documentation: Transaction.currentEntitlements
- Apple Developer Documentation: Product.SubscriptionInfo.Status
- Apple Developer Documentation: VerificationResult
- WWDC 2021 Session 10114: Meet StoreKit 2 (Apple Developer)
- RevenueCat: StoreKit 2 overview and migration notes (RevenueCat blog)
- Apple Developer Documentation: App Store Server API
Share this post
Ready to put this into practice?
AppsOps is the first App Store ops dashboard — PPP-fair pricing for 175 App Store territories, AI metadata localization in 39 languages, AI screenshot localization for 14 Apple device classes, and one-click App Store Connect API push — all from one dashboard, all for $19/month.
Try AppsOps free — no card →