From apple-kit-skills
Implement, review, or improve StoreKit 2 in-app purchases and subscriptions on iOS, including paywalls, transactions, entitlements, purchase flows, and testing.
npx claudepluginhub dpearson2699/swift-ios-skills --plugin apple-kit-skillsThis skill uses the workspace's default tool permissions.
Implement in-app purchases, subscriptions, and paywalls using StoreKit 2 on
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Implement in-app purchases, subscriptions, and paywalls using StoreKit 2 on
iOS 26+. Use the modern Product, Transaction, StoreView, and
SubscriptionStoreView APIs. Avoid the older original StoreKit APIs
(SKProduct, SKPaymentQueue, SKStoreReviewController).
| Type | Enum Case | Behavior |
|---|---|---|
| Consumable | .consumable | Used once, can be repurchased (gems, coins) |
| Non-consumable | .nonConsumable | Purchased once permanently (premium unlock) |
| Auto-renewable | .autoRenewable | Recurring billing with automatic renewal |
| Non-renewing | .nonRenewing | Time-limited access without automatic renewal |
Define product IDs as constants. Fetch products with Product.products(for:).
import StoreKit
enum ProductID {
static let premium = "com.myapp.premium"
static let gems100 = "com.myapp.gems100"
static let monthlyPlan = "com.myapp.monthly"
static let yearlyPlan = "com.myapp.yearly"
static let all: [String] = [premium, gems100, monthlyPlan, yearlyPlan]
}
let products = try await Product.products(for: ProductID.all)
for product in products {
print("\(product.displayName): \(product.displayPrice)")
}
Call product.purchase(options:) and handle all three PurchaseResult cases.
Always verify and finish transactions.
func purchase(_ product: Product) async throws {
let result = try await product.purchase(options: [
.appAccountToken(userAccountToken)
])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await deliverContent(for: transaction)
await transaction.finish()
case .userCancelled:
break
case .pending:
// Ask to Buy or deferred approval -- do not unlock content yet
break
@unknown default:
break
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value): return value
case .unverified(_, let error): throw error
}
}
Start at app launch. Catches purchases from other devices, Family Sharing changes, renewals, Ask to Buy approvals, refunds, and revocations.
@main
struct MyApp: App {
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = listenForTransactions()
}
var body: some Scene {
WindowGroup { ContentView() }
}
func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await StoreManager.shared.updateEntitlements()
await transaction.finish()
}
}
}
}
Use Transaction.currentEntitlements for non-consumable purchases and active
subscriptions. Always check revocationDate.
@Observable
@MainActor
class StoreManager {
static let shared = StoreManager()
var purchasedProductIDs: Set<String> = []
var isPremium: Bool { purchasedProductIDs.contains(ProductID.premium) }
func updateEntitlements() async {
var purchased = Set<String>()
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
purchasedProductIDs = purchased
}
}
struct PremiumGatedView: View {
@State private var state: EntitlementTaskState<VerificationResult<Transaction>?> = .loading
var body: some View {
Group {
switch state {
case .loading: ProgressView()
case .failure: PaywallView()
case .success(let transaction):
if transaction != nil { PremiumContentView() }
else { PaywallView() }
}
}
.currentEntitlementTask(for: ProductID.premium) { state in
self.state = state
}
}
}
Built-in SwiftUI view for subscription paywalls. Handles product loading, purchase UI, and restore purchases automatically.
SubscriptionStoreView(groupID: "YOUR_GROUP_ID")
.subscriptionStoreControlStyle(.prominentPicker)
.subscriptionStoreButtonLabel(.multiline)
.storeButton(.visible, for: .restorePurchases)
.storeButton(.visible, for: .redeemCode)
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
VStack {
Image(systemName: "crown.fill").font(.system(size: 60)).foregroundStyle(.yellow)
Text("Unlock Premium").font(.largeTitle.bold())
Text("Access all features").foregroundStyle(.secondary)
}
}
.containerBackground(.blue.gradient, for: .subscriptionStore)
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
SubscriptionPeriodGroupSet()
}
.subscriptionStoreControlStyle(.picker)
Merchandises multiple products with localized names, prices, and purchase buttons.
StoreView(ids: [ProductID.gems100, ProductID.premium], prefersPromotionalIcon: true)
.productViewStyle(.large)
.storeButton(.visible, for: .restorePurchases)
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
ProductView(id: ProductID.premium) { iconPhase in
switch iconPhase {
case .success(let image): image.resizable().scaledToFit()
case .loading: ProgressView()
default: Image(systemName: "star.fill")
}
}
.productViewStyle(.large)
func checkSubscriptionActive(groupID: String) async throws -> Bool {
let statuses = try await Product.SubscriptionInfo.Status.status(for: groupID)
for status in statuses {
guard case .verified = status.renewalInfo,
case .verified = status.transaction else { continue }
if status.state == .subscribed || status.state == .inGracePeriod {
return true
}
}
return false
}
| State | Meaning |
|---|---|
.subscribed | Active subscription |
.expired | Subscription has expired |
.inBillingRetryPeriod | Payment failed, Apple is retrying |
.inGracePeriod | Payment failed but access continues during grace period |
.revoked | Apple refunded or revoked the subscription |
StoreKit 2 handles restoration via Transaction.currentEntitlements. Add a
restore button or call AppStore.sync() explicitly.
func restorePurchases() async throws {
try await AppStore.sync()
await StoreManager.shared.updateEntitlements()
}
On store views: .storeButton(.visible, for: .restorePurchases)
Verify the legitimacy of the app installation. Use for business model changes or detecting tampered installations (iOS 16+).
func verifyAppPurchase() async {
do {
let result = try await AppTransaction.shared
switch result {
case .verified(let appTransaction):
let originalVersion = appTransaction.originalAppVersion
let purchaseDate = appTransaction.originalPurchaseDate
// Migration logic for users who paid before subscription model
case .unverified:
// Potentially tampered -- restrict features as appropriate
break
}
} catch { /* Could not retrieve app transaction */ }
}
// App account token for server-side reconciliation
try await product.purchase(options: [.appAccountToken(UUID())])
// Consumable quantity
try await product.purchase(options: [.quantity(5)])
// Simulate Ask to Buy in sandbox
try await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])
.onInAppPurchaseStart { product in
return true // Return false to cancel
}
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
.inAppPurchaseOptions { product in
[.appAccountToken(userAccountToken)]
}
// WRONG: No listener -- misses renewals, refunds, Ask to Buy approvals
@main struct MyApp: App {
var body: some Scene { WindowGroup { ContentView() } }
}
// CORRECT: Start listener in App init (see Transaction.updates section above)
// WRONG: Never finished -- reappears in unfinished queue forever
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
// CORRECT: Always finish after delivering content
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
await transaction.finish()
// WRONG: Using unverified transaction -- security risk
let transaction = verification.unsafePayloadValue
// CORRECT: Verify before using
let transaction = try checkVerified(verification)
// AVOID: Original StoreKit (legacy)
let request = SKProductsRequest(productIdentifiers: ["com.app.premium"])
SKPaymentQueue.default().add(payment)
SKStoreReviewController.requestReview()
// PREFERRED: StoreKit 2
let products = try await Product.products(for: ["com.app.premium"])
let result = try await product.purchase()
try await AppStore.requestReview(in: windowScene)
// WRONG: Grants access to refunded purchases
if case .verified(let transaction) = result {
purchased.insert(transaction.productID)
}
// CORRECT: Skip revoked transactions
if case .verified(let transaction) = result, transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
// WRONG: Wrong for other currencies and regions
Text("Buy Premium for $4.99")
// CORRECT: Localized price from Product
Text("Buy \(product.displayName) for \(product.displayPrice)")
// WRONG: Silently drops pending Ask to Buy
default: break
// CORRECT: Inform user purchase is awaiting approval
case .pending:
showPendingApprovalMessage()
// WRONG: Check once, never update
func appDidFinish() { Task { await updateEntitlements() } }
// CORRECT: Re-check on Transaction.updates AND on foreground return
// Transaction.updates listener handles mid-session changes.
// Also use .task { await storeManager.updateEntitlements() } on content views.
// WRONG: No restore option -- App Store rejection risk
SubscriptionStoreView(groupID: "group_id")
// CORRECT
SubscriptionStoreView(groupID: "group_id")
.storeButton(.visible, for: .restorePurchases)
// WRONG: No terms or privacy policy
SubscriptionStoreView(groupID: "group_id")
// CORRECT
SubscriptionStoreView(groupID: "group_id")
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
Transaction.updates listener starts at app launch in App inittransaction.finish() called after content delivery.pending purchase result handled for Ask to Buyproduct.displayPrice, never hardcodedSKProduct, SKPaymentQueue)jwsRepresentation if applicableSendable when shared across concurrency boundaries