Loading...
Loading...
StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, paywall UI, receipt validation, or App Store Connect configuration.
npx skill4agent add peterbamuhigire/skills-web-dev ios-monetizationios-monetizationSKILL.md| Category | Artifact | Format | Example |
|---|---|---|---|
| Correctness | StoreKit 2 purchase flow test plan | Markdown doc covering consumable, non-consumable, subscription, and restore flows | |
| Release evidence | App Store subscription configuration record | Markdown doc capturing product IDs, pricing tiers, and intro offers per region | |
SKPaymentQueueTransaction.updates// 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() }
}
}
}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." }
}Transaction.currentEntitlementsProduct.subscription?.statusstruct 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
}| State | Meaning | Action |
|---|---|---|
| Active | Full access |
| Billing failed, grace period active | Full access + soft prompt |
| Grace expired, Apple retrying | Restricted access + hard prompt |
| Lapsed | Paywall |
| Family sharing revoked | Remove access immediately |
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
}
}// 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])@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)
}
}
}appReceipt/verifyReceiptVerificationResult.verified1. 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)DID_RENEWEXPIREDREFUNDGRACE_PERIOD_EXPIREDREVOKETransaction.currentEntitlementsfunc 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
}Product.products(for:).storekit// 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-Pattern | Consequence | Fix |
|---|---|---|
Start | Miss offline/terminated-app purchases | Start in App init or |
Forget | Re-delivered on every launch, double grants | Always finish after delivery |
Use | Deprecated, triggers App Store login alert unnecessarily | Use |
Poll | Rate limiting, perf degradation | Cache state, invalidate on |
Trust | Security hole — spoofed purchase | Always throw/ignore unverified |
Consumable delivery after | 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 | 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 |
| Show intro offer without eligibility check | Offer silently fails; user confused | Always check |
Transaction.updatestransaction.finish()Transaction.currentEntitlementstransaction.revocationDate != nilAppStore.sync().pending