All posts
AUTOMATION

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.

By the AppsOps team · · 7 min read

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:

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:

  1. 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 a VerificationResult.unverified(...) case instead, which you must treat as a failed purchase.
  2. 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:

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:

~40%of iOS involuntary churn is recovered during Apple's billing retry window, according to directional data from RevenueCat reports

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

Share this post

Ready to put this into practice?

AppsOps is the first App Store ops dashboardPPP-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 →

Related reading