Use when implementing iOS purchases, subscriptions, StoreKit 2 verification, entitlement delivery, paywalls, or App Store server events; use ios-development for non-commerce features.
Use when implementing iOS purchases, subscriptions, StoreKit 2 verification, entitlement delivery, paywalls, or App Store server events; use ios-development for non-commerce features.
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@mainstructMyApp: App {
@StateObjectprivatevar store =StoreService()
var body: someScene {
WindowGroup {
ContentView()
.environmentObject(store)
.task { await store.observeTransactions() }
}
}
}
StoreService — Core Implementation
import StoreKit
@MainActorfinalclassStoreService: ObservableObject {
@Publishedprivate(set)var products: [Product] = []
@Publishedprivate(set)var purchasedProductIDs: Set<String> = []
privatevar transactionObserver: Task<Void, Never>?
init() {
transactionObserver =Task { await observeTransactions() }
}
deinit {
transactionObserver?.cancel()
}
// MARK: - Product LoadingfuncloadProducts(ids: [String]) async {
do {
products =tryawaitProduct.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)funcobserveTransactions() async {
forawait result inTransaction.updates {
await process(result)
}
}
// MARK: - Purchasefuncpurchase(_product: Product,
options: Set<Product.PurchaseOption> = []) asyncthrows -> Transaction? {
let result =tryawait 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 launchreturn transaction
case .userCancelled:
returnnilcase .pending:
// Awaiting Ask to Buy or billing fix — show "payment pending" UIreturnnil@unknowndefault:
returnnil
}
}
// MARK: - Entitlement RefreshfuncrefreshEntitlements() async {
purchasedProductIDs.removeAll()
forawait result inTransaction.currentEntitlements {
guardcase .verified(let transaction) = result else { continue }
guard transaction.revocationDate ==nilelse { continue } // Apple refunded
purchasedProductIDs.insert(transaction.productID)
}
}
// MARK: - Restorefuncrestore() asyncthrows {
// AppStore.sync() triggers re-validation + re-delivery of current entitlements// Do NOT use SKPaymentQueue.restoreCompletedTransactions — it is deprecatedtryawaitAppStore.sync()
await refreshEntitlements()
}
// MARK: - Privateprivatefuncprocess(_result: VerificationResult<Transaction>) async {
await updateEntitlements(try? checkVerified(result))
}
privatefuncupdateEntitlements(_transaction: Transaction?) async {
guardlet transaction else { return }
if transaction.revocationDate ==nil {
purchasedProductIDs.insert(transaction.productID)
} else {
purchasedProductIDs.remove(transaction.productID)
}
}
privatefunccheckVerified<T>(_result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
// JWS signature invalid — tampered receipt or configuration errorthrowStoreError.failedVerification
case .verified(let value):
return value
}
}
}
enumStoreError: 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.
structSubscriptionStatus {
let isActive: Boollet willAutoRenew: Boollet expirationDate: Date?
let isInBillingRetry: Boollet scheduledDowngradeProductID: String?
}
funcsubscriptionStatus(forproduct: Product) async -> SubscriptionStatus? {
guardlet subscription = product.subscription,
let statusArray =try?await subscription.status else { returnnil }
// statusArray contains one entry per subscription in the groupfor status in statusArray {
guardcase .verified(let renewalInfo) = status.renewalInfo,
case .verified(let transaction) = status.transaction else { continue }
let isActive = status.state == .subscribed || status.state == .inGracePeriod
returnSubscriptionStatus(
isActive: isActive,
willAutoRenew: renewalInfo.willAutoRenew,
expirationDate: transaction.expirationDate,
isInBillingRetry: status.state == .inBillingRetryPeriod,
scheduledDowngradeProductID: renewalInfo.autoRenewProductID != product.id
? renewalInfo.autoRenewProductID : nil
)
}
returnnil
}
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.
funcintroOfferDetails(forproduct: Product) async -> Product.SubscriptionOffer? {
guardlet subscription = product.subscription,
await subscription.isEligibleForIntroOffer ==trueelse { returnnil }
return subscription.introductoryOffer
}
// Render based on paymentModefuncintroLabel(_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"@unknowndefault:
return offer.displayPrice
}
}
Promotional Offers (Win-Back / Loyalty)
Promotional offers require a server-generated signature. The signature proves your server authorised the discount.
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:
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.
funcpurchaseConsumable(_product: Product) asyncthrows {
guardlet transaction =tryawait store.purchase(product) else { return }
// Deliver immediately before finish — if app crashes between deliver+finish,// transaction re-delivers on next launch via Transaction.updatesawait deliverConsumable(transaction.productID, quantity: transaction.purchasedQuantity)
await transaction.finish()
}
// Idempotency: store transaction.id in your DB — re-delivery must not double-grantfuncdeliverConsumable(_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.
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 scenariosimport StoreKitTest
classSubscriptionTests: XCTestCase {
var session: SKTestSession!
overridefuncsetUp() asyncthrows {
session =trySKTestSession(configurationFileNamed: "Products")
session.resetToDefaultState()
session.disableDialogs =true
session.timeRate = .monthlyRenewalEveryThirtySeconds
}
functestSubscriptionRenews() asyncthrows {
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.updates loop started at app entry point, not in paywall
All transactions finished with transaction.finish() after delivery
Transaction.currentEntitlements queried on app launch to restore entitlement state
transaction.revocationDate != nil check before granting access
Introductory offer eligibility verified before displaying offer UI
AppStore.sync() called from Restore Purchases button
.pending purchase state handled with visible user feedback
Consumable 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.