storekit
Scannednpx machina-cli add skill dpearson2699/swift-ios-skills/storekit --openclawStoreKit 2 In-App Purchases and Subscriptions
Implement in-app purchases, subscriptions, and paywalls using StoreKit 2 on
iOS 26+. Use only the modern Product, Transaction, StoreView, and
SubscriptionStoreView APIs. Never use the deprecated original StoreKit
(SKProduct, SKPaymentQueue, SKStoreReviewController).
Product Types
| 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 |
Loading Products
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)")
}
Purchase Flow
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
}
}
Transaction.updates Listener
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()
}
}
}
}
Entitlement Checking
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
}
}
SwiftUI .currentEntitlementTask Modifier
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
}
}
}
SubscriptionStoreView (iOS 17+)
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()
}
}
Custom Marketing Content
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)
Hierarchical Layout
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
SubscriptionPeriodGroupSet()
}
.subscriptionStoreControlStyle(.picker)
StoreView (iOS 17+)
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 for Individual Products
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)
Subscription Status Checking
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
}
Renewal States
| 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 |
Restore Purchases
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)
App Transaction (App Purchase Verification)
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 */ }
}
Purchase Options
// 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)])
SwiftUI Purchase Callbacks
.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)]
}
Common Mistakes
1. Not starting Transaction.updates at app launch
// 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)
2. Forgetting transaction.finish()
// 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()
3. Ignoring verification result
// WRONG: Using unverified transaction -- security risk
let transaction = verification.unsafePayloadValue
// CORRECT: Verify before using
let transaction = try checkVerified(verification)
4. Using deprecated original StoreKit APIs
// WRONG: Original StoreKit (deprecated)
let request = SKProductsRequest(productIdentifiers: ["com.app.premium"])
SKPaymentQueue.default().add(payment)
SKStoreReviewController.requestReview()
// CORRECT: StoreKit 2
let products = try await Product.products(for: ["com.app.premium"])
let result = try await product.purchase()
try await AppStore.requestReview(in: windowScene)
5. Not checking revocationDate
// 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)
}
6. Hardcoding prices
// 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)")
7. Not handling .pending purchase result
// WRONG: Silently drops pending Ask to Buy
default: break
// CORRECT: Inform user purchase is awaiting approval
case .pending:
showPendingApprovalMessage()
8. Checking entitlements only once at launch
// 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.
9. Missing restore purchases button
// WRONG: No restore option -- App Store rejection risk
SubscriptionStoreView(groupID: "group_id")
// CORRECT
SubscriptionStoreView(groupID: "group_id")
.storeButton(.visible, for: .restorePurchases)
10. Subscription views without policy links
// 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)
Review Checklist
-
Transaction.updateslistener starts at app launch in App init - All transactions verified before granting access
-
transaction.finish()called after content delivery - Revoked transactions excluded from entitlements
-
.pendingpurchase result handled for Ask to Buy - Restore purchases button visible on paywall and store views
- Terms of Service and Privacy Policy links on subscription views
- Prices shown using
product.displayPrice, never hardcoded - Subscription terms (price, duration, renewal) clearly displayed
- Free trial states post-trial pricing clearly
- No original StoreKit APIs (
SKProduct,SKPaymentQueue) - Product IDs defined as constants, not scattered strings
- StoreKit configuration file set up for testing
- Entitlements re-checked on Transaction.updates and app foreground
- Server-side validation uses
jwsRepresentationif applicable - Consumables delivered and finished promptly
- Transaction observer types and product model types are
Sendablewhen shared across concurrency boundaries
Cross-References
- See
references/app-review-guidelines.mdfor IAP rules (Guideline 3.1.1), subscription display requirements, and rejection prevention. - See
references/storekit-advanced.mdfor subscription control styles, offer management, testing patterns, and advanced subscription handling.
API Verification
The apple-docs MCP server is available for verifying StoreKit API names,
signatures, and availability. Use searchAppleDocumentation with queries
like "Product StoreKit" or fetchAppleDocumentation with paths like
/documentation/storekit/product to cross-check details.
Source
git clone https://github.com/dpearson2699/swift-ios-skills/blob/main/skills/storekit/SKILL.mdView on GitHub Overview
This skill covers implementing StoreKit 2-based IAPs and subscriptions, including paywalls with SubscriptionStoreView/ProductView, and transaction processing with Product and Transaction APIs. It also covers entitlement verification, handling purchase flows (consumable, non-consumable, auto-renewable, non-renewing), offers, subscription status, testing with configuration files, and scenarios like Family Sharing, Ask to Buy, refunds, and billing retries.
How This Skill Works
Load products with Product.products(for:), initiate purchases via product.purchase and handle the PurchaseResult, verify transactions with a VerificationResult and finish, then listen to Transaction.updates to refresh entitlements and respond to renewals, revocations, or refunds.
When to Use It
- Building paywalls with SubscriptionStoreView or ProductView
- Processing purchases and handling Product and Transaction APIs
- Verifying entitlements and monitoring revocationDate
- Setting up StoreKit testing with configuration files
- Managing Family Sharing, Ask to Buy, refunds, and billing retry logic
Quick Start
- Step 1: Define ProductID constants and fetch products with Product.products(for: ProductID.all)
- Step 2: Purchase a product with product.purchase(options: ...), verify with checkVerified, deliverContent, and finish
- Step 3: Initialize a Transaction.updates listener on app launch to update entitlements and finalize transactions
Best Practices
- Define product IDs as constants and fetch products with Product.products(for:)
- Always verify and finish transactions to unlock content
- Start a Transaction.updates listener at app launch to catch cross-device purchases, renewals, and refunds
- Use Transaction.currentEntitlements and revocationDate to gate access
- Test thoroughly with StoreKit Testing, including refunds and retry scenarios
Example Use Cases
- Presenting a monthly auto-renewable subscription via SubscriptionStoreView with correct entitlements
- Offering consumable gems or coins loaded after a successful Product.purchase
- Unlocking a non-consumable feature (premium) and verifying entitlement before access
- Applying introductory offers or promo codes using StoreKit offer mechanisms
- Handling refunds and revocations by processing Transaction.updates and updating entitlements