7.6 — StoreKit 2
Opening scenario
The CEO says: “Add a Pro tier with a monthly and annual subscription, plus a one-time ‘remove ads’ purchase, plus 100-coin and 500-coin consumables. Oh, and Family Sharing, restore-purchases, free trials, and a ‘Cancel anytime’ link.” Five years ago this required SKPaymentQueue, SKPaymentTransactionObserver, server-side receipt validation, base64-encoded payloads, and a 600-line file you copied from an Apple sample project. StoreKit 2 (iOS 15+) replaces all of it with async/await, JSON Web Signature transactions verified on-device, and a single coherent API.
| Context | What it usually means |
|---|---|
| Reads “Product.products(for:)” | Has done basic fetches |
| Reads “Transaction.currentEntitlements” | Knows the receipt-less model |
| Reads “subscription status” | Has built tier-aware UI |
| Reads “App Store Server API” | Has a backend that knows about transactions |
| Reads “App Store Server Notifications V2” | Has handled webhook lifecycle events |
Concept → Why → How → Code
Concept
StoreKit 2 is built on Swift concurrency. Four types you’ll touch:
Product— a SKU you defined in App Store Connect, fetched async by ID.Transaction— a purchase event with a cryptographic signature; verify on-device.Product.SubscriptionInfo.Status— current subscription state (subscribed, in grace, expired, in billing retry).AppStore.sync()— refresh the device’s transaction cache from Apple (rarely needed; iOS does this automatically).
There is no more receipt blob. Instead, each Transaction is a JWS payload your code verifies (Apple’s public key is embedded), and the API exposes the current set of valid entitlements at any moment.
Why
- Async-native — no delegates, no queues, no observer threading bugs.
- On-device verification — no server required to grant entitlements for basic apps.
- App Store Server API + V2 notifications — for server-aware apps (fraud, refunds, cross-platform unlock), the server layer is also JSON-based.
- One library, all platforms — iOS, macOS, watchOS, tvOS, visionOS, Catalyst.
How — products & purchase
import StoreKit
enum ProductID: String, CaseIterable {
case removeAds = "com.example.app.removeAds"
case coins100 = "com.example.app.coins100"
case coins500 = "com.example.app.coins500"
case proMonthly = "com.example.app.pro.monthly"
case proYearly = "com.example.app.pro.yearly"
}
@Observable
@MainActor
final class Store {
var products: [Product] = []
var ownedProductIDs: Set<String> = []
var subscriptionStatus: Product.SubscriptionInfo.Status?
private var updateListener: Task<Void, Never>?
init() {
updateListener = listenForTransactions()
Task { await loadProducts(); await refreshEntitlements() }
}
deinit { updateListener?.cancel() }
func loadProducts() async {
do {
products = try await Product.products(for: ProductID.allCases.map(\.rawValue))
} catch {
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verify(verification)
await refreshEntitlements()
await transaction.finish()
return transaction
case .userCancelled, .pending: return nil
@unknown default: return nil
}
}
private func verify<T>(_ verification: VerificationResult<T>) throws -> T {
switch verification {
case .unverified(_, let error): throw error
case .verified(let value): return value
}
}
}
Entitlements — the modern “what is the user allowed to do”
extension Store {
func refreshEntitlements() async {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
guard case let .verified(transaction) = result else { continue }
owned.insert(transaction.productID)
}
ownedProductIDs = owned
// Subscription status (any group)
if let sub = products.first(where: { $0.subscription != nil })?.subscription {
let statuses = try? await sub.status
subscriptionStatus = statuses?.first
}
}
}
Transaction.currentEntitlements is the source of truth. Don’t store “user is pro” in UserDefaults and trust it; recompute from entitlements on launch and every transaction event.
Listen for store-driven transactions
extension Store {
func listenForTransactions() -> Task<Void, Never> {
Task.detached(priority: .background) {
for await result in Transaction.updates {
guard case let .verified(transaction) = result else { continue }
await self.refreshEntitlements()
await transaction.finish()
}
}
}
}
This catches: family-sharing grants, parental purchase approvals, server-side promotional offers, refunds, billing retries — events that didn’t originate from a product.purchase() call.
Subscription status
if let status = subscriptionStatus {
switch status.state {
case .subscribed:
// Active
case .expired:
// Past due — show paywall again
case .inGracePeriod:
// Payment failed but user still has access; nudge to update payment method
case .inBillingRetryPeriod:
// Payment failing; show recovery banner
case .revoked:
// Apple refunded the user; revoke access
default: break
}
}
status.renewalInfo (also a VerificationResult) tells you whether auto-renew is on, the next renewal product, whether the user is in a promotional offer, etc.
Restore purchases
In StoreKit 2 there’s no special “restore” call — Transaction.currentEntitlements already reflects what the user owns across devices via their Apple ID. Provide a button anyway because users expect it; it can just call await store.refreshEntitlements().
If you suspect the local cache is stale (e.g., user switched Apple IDs):
try await AppStore.sync()
This triggers a sign-in prompt; reserve for “Restore Purchases” button taps.
Promotional offers & introductory offers
Defined in App Store Connect; surfaced via:
if let subscription = product.subscription {
if let intro = subscription.introductoryOffer {
Text("Free trial: \(intro.period.formatted())")
}
for offer in subscription.promotionalOffers {
// Signed server-side, attached at purchase time
let signedOffer = try await server.signPromotionalOffer(productID: product.id,
offerID: offer.id)
try await product.purchase(options: [.promotionalOffer(offerID: offer.id, signature: signedOffer)])
}
}
Promotional offers require server-side signing with your .p8 key; the API surface here just attaches the signed offer to the purchase call.
Server-side: App Store Server API & Notifications V2
For fraud detection, cross-platform entitlement, server-side unlock:
- App Store Server API — REST. Given a transaction ID or original transaction ID, fetch all transactions, subscription history, refund history.
- App Store Server Notifications V2 — Apple POSTs JWS-signed JSON to your webhook for
SUBSCRIBED,DID_RENEW,DID_FAIL_TO_RENEW,EXPIRED,REFUND,CONSUMPTION_REQUEST, etc. Verify the JWS chain, update your DB, optionally push the user.
Testing
- StoreKit Configuration File (Xcode → File → New → File → StoreKit Configuration) — local mock products. Runs in the simulator, no Apple Developer account needed for the basics.
- Sandbox testers in App Store Connect → Users & Access → Sandbox.
- Subscriptions in sandbox renew accelerated — 1 month becomes 5 minutes; 1 year becomes 1 hour. Plan your test sessions accordingly.
In the wild
- Bear, Things 3, Day One — pure StoreKit 2 with one-time unlock or subscription tiers.
- Spotify (Reader app), Netflix — do not use StoreKit for subscription signup (reader app exemption — sign up on web, log in in app).
- Calm, Headspace — full StoreKit 2 subscriptions with intro offers, promotional offers, win-back campaigns.
- Duolingo Super — StoreKit with aggressive yearly conversion paywall.
- Procreate — single non-consumable IAP, the simplest StoreKit story.
Common misconceptions
- “I need to validate receipts on my server.” Not for entitlement gating.
Transaction.currentEntitlementsis signed and verified on-device. Server validation matters for fraud detection, cross-platform unlock, and revenue attribution — not for “is this user pro right now.” - “
product.purchase()returns the transaction immediately.” It returns aPurchaseResult..successcarries aVerificationResult<Transaction>you must verify. Always also handle.userCancelledand.pending(Ask to Buy). - “Subscriptions auto-renew without my app running.” They do, but you don’t see the new transaction until the user opens the app or you fetch it via the App Store Server API. Build server-side awareness for billing-cycle-driven UX.
- “
AppStore.sync()should run on every launch.” It triggers a sign-in prompt. Reserve for explicit Restore Purchases taps. - “I can use StoreKit for tipping.” Tips that grant no entitlement use a special Tip Jar category of non-consumables. Selling tangible goods or external services with IAP is forbidden — use Stripe/PayPal.
Seasoned engineer’s take
StoreKit 2 is good. Annoyingly good. After a decade of receipt-validation hell, having a typed Swift API that just says “here are the user’s current entitlements” feels like cheating. Lean in:
- Build a single
Storeactor/observable and inject it everywhere. Don’t sprinkle StoreKit calls across view models. One owner, one source of truth. - Always re-derive entitlements; never cache “is pro” as a boolean. A refund or family-sharing change must be visible within seconds of the next app launch.
- Test the paywall on a real device with sandbox. Simulator + StoreKit Config catches the happy path; real sandbox catches the “Ask to Buy”, “billing retry”, and “tax dialog” flows.
For subscriptions specifically, the unlock UX is more important than the purchase UX. When the user’s payment fails and they enter billing retry, your app should show a calm “Your subscription is about to expire — update payment method” banner, not silently revoke access. This costs 50 lines of UI and saves thousands in churn.
TIP: If your app sells across iOS, macOS, and a web tier, use RevenueCat (covered in Chapter 11.5). It wraps StoreKit 2 + Google Play + Stripe with a single entitlement abstraction and removes the cross-platform “is this user entitled?” headache.
WARNING: Never extend access beyond what
Transaction.currentEntitlementsreports. “But the user paid us yesterday” doesn’t survive an App Review audit; if Apple’s source of truth says expired, your app must reflect expired. Server-side overrides for legitimate edge cases must be auditable.
Interview corner
Junior: “How do you sell a non-consumable IAP and know the user owns it?”
Define the product in App Store Connect. Load it with
Product.products(for: ["productID"]). On user tap, callawait product.purchase(). Verify the result, finish the transaction. On every launch, iterateTransaction.currentEntitlementsand check if your product ID is present.
Mid: “How do you handle a subscription that lapses while the app is in the background?”
Subscribe to
Transaction.updatesfrom app launch in a long-livedTask. When Apple delivers a renewal event (or expiry, or refund) the iterator yields aVerificationResult<Transaction>. Verify, re-fetchTransaction.currentEntitlements, finish the transaction, update UI. For server-side awareness (e.g., to send win-back email), wire App Store Server Notifications V2 to a webhook that updates your DB and triggers the campaign.
Senior: “Design a cross-platform subscription system: iOS, web (Stripe), Android. User pays once on any platform, gets access everywhere.”
Single source of truth is your server’s “entitlements” table keyed by user ID. iOS: StoreKit 2 transaction events flow to your backend via App Store Server Notifications V2 — server creates/updates the entitlement. Stripe webhook does the same for web. Google Play Developer API + Real-Time Developer Notifications for Android. iOS client fetches entitlements from your server on launch (in addition to
Transaction.currentEntitlementsfor offline grace), and treats server-granted entitlements as primary. Apple compliance: do not advertise web/Android purchases inside the iOS app (anti-steering) — but the unlock works once the user signs in. Sign in with Apple is convenient as the cross-platform identity layer. For UX during signup, optionally use RevenueCat to wrap all three stores behind one identifier.
Red flag: “We cache isPro = true in UserDefaults after purchase and trust it forever.”
Two failures: it doesn’t refresh after refund/family-sharing changes, and
UserDefaultsisn’t tamper-proof — a jailbroken device can flip the bit. Server-validated entitlement orTransaction.currentEntitlementschecked at every relevant boundary is the standard.
Lab preview
Lab 7.3 — StoreKit 2 IAP builds a complete paywall: non-consumable + monthly/yearly subscription, intro offer, restore-purchases button, sandbox-tested across the full lifecycle.
Next: 7.7 — ARKit basics