iOS Monetization — StoreKit 2
Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.
Use When
- StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, group or organization subscription watch items, Unity StoreKit plugin routing, paywall UI, receipt validation, or App Store Connect configuration.
Evidence Produced
| Category | Artifact | Format | Example |
|---|---|---|---|
| Correctness | StoreKit 2 purchase flow test plan | Markdown doc covering consumable, non-consumable, subscription, and restore flows | docs/ios/storekit-tests-checkout.md |
| Release evidence | App Store subscription configuration record | Markdown doc capturing product IDs, pricing tiers, and intro offers per region | docs/ios/subscription-config-2026-04-16.md |
References
- Use the links and companion skills already referenced in this file when deeper context is needed.
Architecture Principles
StoreKit 2 is async/await-native. The entire SKPaymentQueue/delegate callback model is abandoned. Every purchase, verification, and entitlement check is a Swift concurrency operation.
WWDC26 App Store Watch Items
- Review current Apple In-App Purchase and subscription sessions before using new group or organization subscription behavior.
- Unity projects should review Apple's official Unity StoreKit plugin before building a custom bridge.
- Treat App Store Small Business Program, Private Cloud Compute model access, and subscription eligibility as separate business rules; do not mix them in entitlement code.
- Keep StoreKit test scenarios in Xcode 27 current: interrupted purchases, refunds, revocations, billing retry, grace period, Ask to Buy, and restore.
Transaction observer is not optional. It must start at app entry point — before any UI renders. Transactions that completed while the app was terminated are delivered via Transaction.updates on next launch. Starting this loop in the paywall means you silently drop those deliveries.
// App.swift — Swift 6 actor-isolated entry point
@main
struct MyApp: App {
@StateObject private var store = StoreService()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
.task { await store.observeTransactions() }
}
}
}
StoreService — Core Implementation
import StoreKit
@MainActor
final class StoreService: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private var transactionObserver: Task<Void, Never>?
init() {
transactionObserver = Task { await observeTransactions() }
}
deinit {
transactionObserver?.cancel()
}
// MARK: - Product Loading
func loadProducts(ids: [String]) async {
do {
products = try await Product.products(for: ids)
// Products come back unordered — sort by price or custom order
products.sort { $0.price < $1.price }
} catch {
// StoreKitError.networkError — retry with backoff
// StoreKitError.notEntitled — sandbox/config issue
}
}
// MARK: - Transaction Observer (MUST run for app lifetime)
func observeTransactions() async {
for await result in Transaction.updates {
await process(result)
}
}
// MARK: - Purchase
func purchase(_ product: Product,
options: Set<Product.PurchaseOption> = []) async throws -> Transaction? {
let result = try await product.purchase(options: options)
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updateEntitlements(transaction)
await transaction.finish() // CRITICAL — unfinished = re-delivered on next launch
return transaction
case .userCancelled:
return nil
case .pending:
// Awaiting Ask to Buy or billing fix — show "payment pending" UI
return nil
@unknown default:
return nil
}
}
// MARK: - Entitlement Refresh
func refreshEntitlements() async {
purchasedProductIDs.removeAll()
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
guard transaction.revocationDate == nil else { continue } // Apple refunded
purchasedProductIDs.insert(transaction.productID)
}
}
// MARK: - Restore
func restore() async throws {
// AppStore.sync() triggers re-validation + re-delivery of current entitlements
// Do NOT use SKPaymentQueue.restoreCompletedTransactions — it is deprecated
try await AppStore.sync()
await refreshEntitlements()
}
// MARK: - Private
private func process(_ result: VerificationResult<Transaction>) async {
await updateEntitlements(try? checkVerified(result))
}
private func updateEntitlements(_ transaction: Transaction?) async {
guard let transaction else { return }
if transaction.revocationDate == nil {
purchasedProductIDs.insert(transaction.productID)
} else {
purchasedProductIDs.remove(transaction.productID)
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
// JWS signature invalid — tampered receipt or configuration error
throw StoreError.failedVerification
case .verified(let value):
return value
}
}
}
enum StoreError: LocalizedError {
case failedVerification
var errorDescription: String? { "Purchase could not be verified." }
}
Subscription Status — Full Detail
Transaction.currentEntitlements gives current owned state. Product.subscription?.status gives renewal metadata.
struct SubscriptionStatus {
let isActive: Bool
let willAutoRenew: Bool
let expirationDate: Date?
let isInBillingRetry: Bool
let scheduledDowngradeProductID: String?
}
func subscriptionStatus(for product: Product) async -> SubscriptionStatus? {
guard let subscription = product.subscription,
let statusArray = try? await subscription.status else { return nil }
// statusArray contains one entry per subscription in the group
for status in statusArray {
guard case .verified(let renewalInfo) = status.renewalInfo,
case .verified(let transaction) = status.transaction else { continue }
let isActive = status.state == .subscribed || status.state == .inGracePeriod
return SubscriptionStatus(
isActive: isActive,
willAutoRenew: renewalInfo.willAutoRenew,
expirationDate: transaction.expirationDate,
isInBillingRetry: status.state == .inBillingRetryPeriod,
scheduledDowngradeProductID: renewalInfo.autoRenewProductID != product.id
? renewalInfo.autoRenewProductID : nil
)
}
return nil
}
Subscription states to handle:
| State | Meaning | Action |
|---|---|---|
.subscribed |
Active | Full access |
.inGracePeriod |
Billing failed, grace period active | Full access + soft prompt |
.inBillingRetryPeriod |
Grace expired, Apple retrying | Restricted access + hard prompt |
.expired |
Lapsed | Paywall |
.revoked |
Family sharing revoked | Remove access immediately |
Introductory Offers
Intro offers are Apple ID-scoped — once consumed, the user is ineligible forever. Check before displaying.
func introOfferDetails(for product: Product) async -> Product.SubscriptionOffer? {
guard let subscription = product.subscription,
await subscription.isEligibleForIntroOffer == true else { return nil }
return subscription.introductoryOffer
}
// Render based on paymentMode
func introLabel(_ offer: Product.SubscriptionOffer) -> String {
switch offer.paymentMode {
case .freeTrial:
return "Free for \(offer.period.localizedDescription)"
case .payAsYouGo:
return "\(offer.displayPrice)/\(offer.period.value) \(offer.period.unit) for \(offer.periodCount) periods"
case .payUpFront:
return "\(offer.displayPrice) for \(offer.periodCount) periods"
@unknown default:
return offer.displayPrice
}
}
Promotional Offers (Win-Back / Loyalty)
Promotional offers require a server-generated signature. The signature proves your server authorised the discount.
// Server returns: keyID, nonce, signature, timestamp
let offerID = "annual_winback_50"
let signature = try await fetchPromoSignature(productID: product.id, offerID: offerID)
let purchaseOption = Product.PurchaseOption.promotionalOffer(
offerID: offerID,
keyID: signature.keyID,
nonce: signature.nonce,
signature: signature.data,
timestamp: signature.timestamp
)
let transaction = try await storeService.purchase(product, options: [purchaseOption])
Never hard-code or generate signatures client-side — App Store will reject them.
Paywall ViewModel
@MainActor
final class PaywallViewModel: ObservableObject {
enum PurchaseState: Equatable {
case idle, loading, purchasing, purchased, failed(String)
}
@Published var products: [Product] = []
@Published var purchaseState: PurchaseState = .idle
@Published var selectedProduct: Product?
@Published var introOffer: Product.SubscriptionOffer?
private let store: StoreService
init(store: StoreService) {
self.store = store
}
func load(productIDs: [String]) async {
purchaseState = .loading
await store.loadProducts(ids: productIDs)
products = store.products
selectedProduct = products.first(where: { $0.type == .autoRenewable })
if let selected = selectedProduct {
introOffer = await introOfferDetails(for: selected)
}
purchaseState = .idle
}
func purchase() async {
guard let product = selectedProduct else { return }
purchaseState = .purchasing
do {
guard try await store.purchase(product) != nil else {
// userCancelled or pending — no error, just reset
purchaseState = .idle
return
}
purchaseState = .purchased
} catch {
purchaseState = .failed(error.localizedDescription)
}
}
func restore() async {
purchaseState = .loading
do {
try await store.restore()
purchaseState = store.purchasedProductIDs.isEmpty ? .idle : .purchased
} catch {
purchaseState = .failed(error.localizedDescription)
}
}
}
Receipt Validation — JWS vs Legacy
StoreKit 2 signs every transaction as a JWS (JSON Web Signature). You do not need the old base64 appReceipt + /verifyReceipt endpoint.
Client-side (sufficient for most apps):
VerificationResult.verified means Apple's signature checked out locally. Use this.
Server-side (required for high-value entitlements, fraud prevention):
1. Decode JWS: split by ".", base64url-decode payload
2. Verify signature using Apple's public key from WWDR certificate chain
3. Check: environment, bundleID, productID, expirationDate, revocationDate
4. Use App Store Server API for real-time status (not polled receipts)
App Store Server Notifications v2 (webhooks) push events to your server:
DID_RENEW,EXPIRED,REFUND,GRACE_PERIOD_EXPIRED,REVOKE
Register the endpoint in App Store Connect > App Information > App Store Server Notifications.
Consumables — Delivery Pattern
Consumables are not tracked by Transaction.currentEntitlements. You must persist delivery yourself.
func purchaseConsumable(_ product: Product) async throws {
guard let transaction = try await store.purchase(product) else { return }
// Deliver immediately before finish — if app crashes between deliver+finish,
// transaction re-delivers on next launch via Transaction.updates
await deliverConsumable(transaction.productID, quantity: transaction.purchasedQuantity)
await transaction.finish()
}
// Idempotency: store transaction.id in your DB — re-delivery must not double-grant
func deliverConsumable(_ productID: String, quantity: Int) async {
// Check if transaction.id already processed before crediting
}
App Store Connect Configuration — Critical Steps
- Create IAPs before running on device — Xcode cannot synthesise them
- Subscription Group required before adding Auto-Renewable subscriptions; group name is user-visible in cancellation flow
- All tiers in the same subscription group share one active subscription; Apple handles proration on upgrades automatically
- Localisations on products are required — missing localisation = product not returned by
Product.products(for:) - Tax categories must be set (Software, Newspaper, etc.) — affects storefront availability
- Pricing: set a base territory first, then "Sync" to all territories — do not set each manually
- Sandbox testers created in App Store Connect > Users and Access > Sandbox Testers — use a new Apple ID, not your own
StoreKit Configuration File (Local Testing)
Add a .storekit file to the Xcode project, configure via Edit Scheme > Run > Options > StoreKit Configuration. This bypasses App Store Connect entirely.
Non-obvious capabilities of the config file:
- Simulate interrupted purchases (requires StoreKit testing in-process)
- Set transaction speed to "Monthly" = 1 minute, "Annual" = 12 minutes in sandbox
- Trigger refunds and revocations from Xcode debug menu
- Test subscription state transitions without waiting for real time
// In XCTest — use SKTestSession to script scenarios
import StoreKitTest
class SubscriptionTests: XCTestCase {
var session: SKTestSession!
override func setUp() async throws {
session = try SKTestSession(configurationFileNamed: "Products")
session.resetToDefaultState()
session.disableDialogs = true
session.timeRate = .monthlyRenewalEveryThirtySeconds
}
func testSubscriptionRenews() async throws {
let store = StoreService()
// purchase → wait 30s → verify renewal transaction delivered
}
}
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
Start Transaction.updates in paywall |
Miss offline/terminated-app purchases | Start in App init or @main .task |
Forget transaction.finish() |
Re-delivered on every launch, double grants | Always finish after delivery |
Use SKPaymentQueue.restoreCompletedTransactions |
Deprecated, triggers App Store login alert unnecessarily | Use AppStore.sync() |
Poll isSubscribed() on every onAppear |
Rate limiting, perf degradation | Cache state, invalidate on Transaction.updates |
Trust .unverified transactions |
Security hole — spoofed purchase | Always throw/ignore unverified |
Consumable delivery after finish() |
Lost delivery if crash between them | Deliver first, then finish |
| Test with production Apple ID | Real charges, irreversible | Always use sandbox account |
| One product ID for multiple tiers | Cannot offer upgrade pricing or group logic | Separate product per tier |
Not handling .pending state |
User sees no feedback; assume purchase failed | Show "payment pending" UI |
| Client-side promo offer signatures | Rejected by App Store | Server-generated only |
| Infer subscription active from purchase date + duration | Clock skew, grace periods, billing retry | Use Transaction.currentEntitlements |
| Show intro offer without eligibility check | Offer silently fails; user confused | Always check isEligibleForIntroOffer |
Launch Checklist
Decision Rules
| Product | StoreKit choice |
|---|---|
| Durable unlock | Non-consumable |
| Renewable time-based access | Auto-renewable subscription with server entitlement state |
| Repeatable unit consumed by use | Consumable with idempotent delivery ledger |
| Purchase cannot be cryptographically verified | Withhold entitlement and retry verification |
Degraded Mode
Without App Store Connect, sandbox accounts, or server notifications, produce configuration and test matrices but mark purchase, restore, renewal, refund, grace-period, and revocation paths unverified. If a required capability is unavailable, withhold launch approval for the affected purchase path.
Domain Anti-Patterns
Granting entitlement before transaction verification. Fix: verify first, then record delivery atomically.
Trusting only local subscription state. Fix: reconcile signed server events and current status.
Delivering a consumable twice after retry. Fix: key delivery by transaction identifier.
Omitting restore behaviour. Fix: expose and test a restore path.
Hiding price or renewal terms. Fix: display StoreKit-localised terms before purchase.
Transaction.updatesloop started at app entry point, not in paywallAll transactions finished with
transaction.finish()after deliveryTransaction.currentEntitlementsqueried on app launch to restore entitlement statetransaction.revocationDate != nilcheck before granting accessIntroductory offer eligibility verified before displaying offer UI
AppStore.sync()called from Restore Purchases button.pendingpurchase state handled with visible user feedbackConsumable delivery is idempotent (transaction ID deduplication)
Sandbox test accounts created in App Store Connect
StoreKit Config File added for local automated testing
App Store Server Notifications v2 endpoint registered for subscriptions
Server-side JWS validation implemented for high-value entitlements
Subscription group configured in App Store Connect before testing
All product localisations complete — missing localisation silently drops product
Inputs
| Artefact | Required? | Purpose |
|---|---|---|
| Product catalogue, entitlement rules, StoreKit configuration, pricing, and server contract | yes | Design purchase lifecycle |
Outputs
- Produce monetisation implementation or design with entitlement, receipt, restore, refund, and test evidence.
Capability contract
Store configuration, price changes, sandbox purchases, receipt validation, and entitlement mutation require explicit environment and account authority.