11.4 — StoreKit 2 Business Patterns
Opening scenario
A user emails: “I paid for Pro last year. The app forgot. Help.” You dig in: the receipt validation worked at purchase, the bool got cached in UserDefaults, then a fresh install nuked it. No receipt re-check. No source of truth other than a local cache. You apologize and grant a courtesy refund. Then you find 47 similar tickets in the queue from the past year. Your real revenue is ~5% higher than your dashboard suggests, and your trust is leaking.
StoreKit 2’s receipt-less, await-native API was designed to make these bugs structurally impossible — if you adopt it correctly.
Context taxonomy
| Concept | StoreKit 1 (legacy) | StoreKit 2 (iOS 15+) |
|---|---|---|
| Receipt | Single binary blob, parsed via OpenSSL or server validation | Per-transaction JWS, verified locally with Transaction.verificationResult |
| Source of truth | Receipt file at Bundle.main.appStoreReceiptURL | Async streams: Transaction.currentEntitlements, Transaction.updates, Transaction.unfinished |
| Transaction finishing | SKPaymentQueue.default().finishTransaction() | await transaction.finish() |
| Renewal events | StoreKit 1 didn’t surface them client-side; required server-side notifications | Transaction.updates async sequence |
| Server validation | Hit verifyReceipt endpoint (now deprecated) | App Store Server API (REST, JWT) |
| Server notifications | V1, single-shot, often dropped | V2, signed JWS, retry, idempotent |
Concept → Why → How → Code
Concept. StoreKit 2 replaces the legacy “single receipt blob” model with a stream of cryptographically signed transactions. The client treats Apple’s transaction store as the source of truth; the server augments with fraud detection, cross-device entitlement, and refund handling via the App Store Server API + Server Notifications V2 webhooks.
Why. Local caches drift, get nuked, and contradict Apple. The StoreKit 2 model bakes “Apple is the source of truth” into the API surface: you can’t accidentally cache an entitlement when the canonical way to read it is await Transaction.currentEntitlements.
Pattern 1 — entitlement check (the only correct way)
// EntitlementService.swift
import StoreKit
actor EntitlementService {
static let shared = EntitlementService()
private(set) var isPro: Bool = false
private var updatesTask: Task<Void, Never>?
func start() {
// 1. Process anything missed while app was closed
Task { await refresh() }
// 2. Subscribe to ongoing updates (renewals, refunds, family share changes)
updatesTask = Task.detached { [weak self] in
for await update in Transaction.updates {
await self?.handle(update)
}
}
}
func refresh() async {
var hasPro = false
for await result in Transaction.currentEntitlements {
guard case .verified(let txn) = result else { continue }
if txn.productID.hasPrefix("com.acme.pro") && txn.revocationDate == nil {
hasPro = true
}
}
isPro = hasPro
}
private func handle(_ result: VerificationResult<Transaction>) async {
guard case .verified(let txn) = result else { return }
await refresh()
await txn.finish()
}
}
Why this is the only correct pattern:
Transaction.currentEntitlementsis the live set of active entitlements. There is no cache to invalidate.Transaction.updatesfires on renewals, refunds, expirations, family share grants — all the events that legacy receipts forced you to poll for.- Both are async sequences; SwiftUI/UIKit observe them via
Taskand re-render naturally.
Pattern 2 — purchase flow
// PaywallViewModel.swift
import StoreKit
@Observable
final class PaywallViewModel {
var products: [Product] = []
var purchasing = false
var lastError: String?
func loadProducts() async {
do {
products = try await Product.products(for: [
"com.acme.pro.monthly",
"com.acme.pro.annual",
"com.acme.pro.lifetime",
])
} catch {
lastError = error.localizedDescription
}
}
func purchase(_ product: Product) async {
purchasing = true; defer { purchasing = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let txn) = verification {
await EntitlementService.shared.refresh()
await txn.finish()
}
case .userCancelled:
break
case .pending:
// Ask-to-Buy (parental approval), SCA — wait for Transaction.updates
lastError = "Awaiting approval"
@unknown default:
break
}
} catch {
lastError = error.localizedDescription
}
}
}
The .pending case is the production bug that bites every team: a child making a purchase under Ask-to-Buy returns .pending immediately, and the actual purchase result arrives minutes-to-hours later via Transaction.updates. Your UI must handle it.
Pattern 3 — server validation (fraud + audit)
For high-value subscriptions or fraud-sensitive flows, validate transactions server-side via the App Store Server API.
# JWT for App Store Server API (same key as ASC API)
TOKEN=$(python3 scripts/asc_jwt.py)
# Fetch a transaction by its id
curl -H "Authorization: Bearer $TOKEN" \
"https://api.storekit.itunes.apple.com/inApps/v1/transactions/$TRANSACTION_ID" \
| jq '.signedTransactionInfo'
The response is a JWS you verify server-side with Apple’s published public keys. Once verified, you trust the bundled JSON: productId, purchaseDate, expiresDate, appAccountToken, etc.
Pattern 4 — Server Notifications V2 (webhooks)
App Store Server Notifications V2 push lifecycle events to your server: SUBSCRIBED, DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, REFUND, REVOKE, GRACE_PERIOD_EXPIRED, OFFER_REDEEMED. Each notification is a signed JWS.
# Webhook handler (FastAPI-style)
from fastapi import FastAPI, Request, HTTPException
import jwt
from apple_jwks import get_apple_signing_key # your helper
app = FastAPI()
@app.post("/storekit/notifications")
async def notify(req: Request):
body = await req.json()
signed = body["signedPayload"]
# 1. Verify signature against Apple's published JWKs
key = get_apple_signing_key(signed)
payload = jwt.decode(signed, key, algorithms=["ES256"])
# 2. Unwrap inner signed payloads
notif_type = payload["notificationType"]
subtype = payload.get("subtype")
txn_info = jwt.decode(payload["data"]["signedTransactionInfo"], key, algorithms=["ES256"])
renewal_info = jwt.decode(payload["data"]["signedRenewalInfo"], key, algorithms=["ES256"])
# 3. Update your DB: subscription state machine transitions
await update_subscription(
user_token=txn_info["appAccountToken"],
event=notif_type,
subtype=subtype,
expires=txn_info["expiresDate"],
auto_renew=renewal_info["autoRenewStatus"],
)
# 4. ALWAYS return 200 — Apple retries on non-200, can cause duplicates
return {"ok": True}
Critical: idempotency. Apple retries notifications. Key your DB writes on txn_info["transactionId"] and dedupe.
Subscription state machine
┌──────────────┐
OFFER_REDEEMED ──────►│ │
SUBSCRIBED ────────►│ ACTIVE │
│ │
└──┬───────┬───┘
│ │
DID_FAIL_TO_RENEW DID_RENEW
│ │
▼ └─────► stays ACTIVE
┌─────────────────┐
│ BILLING_RETRY │
│ (grace period) │
└────┬────────┬───┘
RECOVERED │ │ GRACE_PERIOD_EXPIRED
│ │
▼ ▼
ACTIVE EXPIRED
│
│ user resubscribes
▼
ACTIVE
REFUND/REVOKE from any state ──► REVOKED
Code your entitlement check to consult this state machine, not just expiresDate > now(). A user in BILLING_RETRY should still have entitlement; a REVOKED user should lose it instantly even if their expiresDate is in the future.
In the wild
- Apollo for Reddit used StoreKit 2 from launch, citing radical simplification vs StoreKit 1’s receipt blob model.
- Bear Notes validates subscriptions via App Store Server API for cross-device entitlement (iOS, iPad, macOS).
- Things 3 ships a one-time IAP per platform — no subscription — but uses StoreKit 2’s transaction stream to handle reinstalls and family sharing without bug reports.
- Linear (web-first) uses webhooks only for its iOS in-app trial-to-paid conversions — webhook reconciles against their main billing system in Stripe.
Common misconceptions
- “I can cache
isProinUserDefaultsfor performance.” You can, but reload fromTransaction.currentEntitlementson every cold start. The cache is a render hint, not a source of truth. - “App Store Server Notifications V2 is at-most-once.” It’s at-least-once. Dedupe by transaction ID.
- “
Transaction.updatesonly fires for new purchases.” It fires for every lifecycle event: renewals, family share grants, refunds, revocations. Treat it as a stream of state-change events. - “
appAccountTokenis set automatically.” No — you opt in by settingpurchase(options:)Purchase.Option.appAccountToken(token)at purchase time. Without it, you can’t cross-link Apple transactions to your own users. - “You don’t need a server for StoreKit 2.” True for single-device apps. False for any app with login, cross-device entitlement, or fraud-sensitive billing.
Seasoned engineer’s take
The single most important business pattern: server is source of truth for entitlements, client is source of truth for purchase intent. The client tells your server “user just purchased X”; the server independently verifies via App Store Server API; the server tells the client “you have access to Y”. The client never grants itself access.
TIP. Always set
appAccountTokento your own user ID at purchase time. It survives reinstalls, sandboxes the transaction to a user, and is the only reliable join key between Apple’s transaction world and your user database.
WARNING. Refund notifications (
REFUND,REFUND_DECLINED) can arrive months after the original purchase. Your subscription state machine must handle late-arriving revocations. Apps that don’t, leak revenue and confuse users.
The mindset shift StoreKit 2 demands: stop thinking about “do I have a valid receipt?” and start thinking “what does Apple currently say about this user’s entitlements?” Same answer, very different reliability.
Interview corner
Junior — “How do you check if a user has a paid subscription in StoreKit 2?” Iterate Transaction.currentEntitlements, filter for verified transactions matching your product ID with no revocationDate. Done.
Mid — “Why subscribe to Transaction.updates?” It’s how renewals, refunds, family share grants, and ask-to-buy approvals reach you. Without it, you miss state changes between launches.
Senior — “Design a server-side subscription system for a cross-platform app (iOS + web).” Web payments via Stripe; iOS payments via StoreKit 2; both write to a unified subscriptions table keyed by user ID with provider-specific transaction IDs. iOS purchases set appAccountToken = user_id. Server validates each iOS purchase via App Store Server API at purchase time and on every Server Notification V2. Entitlement reads always go through the server’s /me/entitlements endpoint, never trust client-cached values for paywall decisions. Reconcile job runs daily to catch missed webhooks. Refunds, revocations, and dispute resolution are server-driven.
Red flag — “We just cache isPro in UserDefaults after purchase.” Cold installs lose it, refunds don’t update it, family sharing doesn’t trigger it. Symptom: customer-service backlog about “lost” subscriptions.
Lab preview
Lab 11.1 implements EntitlementService and the paywall view model exactly as outlined here, wired through RevenueCat (which abstracts the StoreKit boilerplate while exposing the same model).