Use when implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling - testing-first workflow with .storekit configuration, StoreManager architecture, transaction verification, subscription management, and restore purchases for consumables, non-consumables, and auto-renewable subscriptions
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Purpose: Guide robust, testable in-app purchase implementation StoreKit Version: StoreKit 2 iOS Version: iOS 15+ (iOS 18.4+ for latest features) Xcode: Xcode 13+ (Xcode 16+ recommended) Context: WWDC 2025-241, 2025-249, 2023-10013, 2021-10114
✅ Use this skill when:
❌ Do NOT use this skill for:
If you wrote purchase code before creating .storekit configuration, you have three options:
Delete all IAP code and follow the testing-first workflow below. This reinforces correct habits and ensures you experience the full benefit of .storekit-first development.
Why this is best:
Create the .storekit file now with your existing product IDs. Test everything works locally. Document in your PR that you tested in sandbox first.
Trade-offs:
If choosing this path: Create .storekit immediately, verify locally, and commit a note explaining the approach.
Commit without .storekit configuration, test only in sandbox.
Why this is problematic:
Bottom line: Choose Option A if possible, Option B if pragmatic, never Option C.
Best Practice: Create and test StoreKit configuration BEFORE writing production purchase code.
The recommended workflow is to create .storekit configuration before writing any purchase code. This isn't arbitrary - it provides concrete benefits:
Immediate product ID validation:
Faster iteration:
Team benefits:
Common objections addressed:
❓ "I already tested in sandbox" - Sandbox testing is valuable but comes later. Local testing with .storekit is faster and enables true TDD.
❓ "My code works" - Working code is great! Adding .storekit makes it easier for teammates to verify and maintain.
❓ "I've done this before" - Experience is valuable. The .storekit-first workflow makes experienced developers even more productive.
❓ "Time pressure" - Creating .storekit takes 10-15 minutes. The time saved in iteration pays back immediately.
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
↓ ↓ ↓ ↓ ↓
.storekit Test purchases StoreManager Mock store Integration test
Why this order helps:
Benefits of following this workflow:
Before marking IAP implementation complete, ALL items must be verified:
.storekit configuration file with all productsTransaction.updatesVerificationResult.finish() after entitlement grantedpurchase(confirmIn:options:) with UI context (iOS 18.2+)PurchaseResult cases (success, userCancelled, pending)Product.SubscriptionInfo.StatusTransaction.currentEntitlements(for:)Transaction.currentEntitlements or Transaction.allDO THIS BEFORE WRITING ANY PURCHASE CODE.
Products.storekit (or your app name)Click "+" and add each product type:
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
Products.storekitAll purchase logic must go through a single StoreManager. No scattered Product.purchase() calls throughout app.
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
// Published state for UI
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
// Product IDs from StoreKit configuration
private let productIDs = [
"com.yourapp.coins_100",
"com.yourapp.premium",
"com.yourapp.pro_monthly"
]
private var transactionListener: Task<Void, Never>?
init() {
// Start transaction listener immediately
transactionListener = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
transactionListener?.cancel()
}
}
Why @MainActor: Published properties must update on main thread for UI binding.
extension StoreManager {
func loadProducts() async {
do {
// Load products from App Store
let loadedProducts = try await Product.products(for: productIDs)
// Update published property on main thread
self.products = loadedProducts
} catch {
print("Failed to load products: \(error)")
// Show error to user
}
}
}
Call from: App.init() or first view's .task modifier
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
// Listen for ALL transaction updates
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
// Verify transaction signature
guard let transaction = try? result.payloadValue else {
print("Transaction verification failed")
return
}
// Grant entitlement to user
await grantEntitlement(for: transaction)
// CRITICAL: Always finish transaction
await transaction.finish()
// Update purchased products
await updatePurchasedProducts()
}
}
Why detached: Transaction listener runs independently of view lifecycle
extension StoreManager {
func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
// Perform purchase with UI context for payment sheet
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verificationResult):
// Verify the transaction
guard let transaction = try? verificationResult.payloadValue else {
print("Transaction verification failed")
return false
}
// Grant entitlement
await grantEntitlement(for: transaction)
// CRITICAL: Finish transaction
await transaction.finish()
// Update state
await updatePurchasedProducts()
return true
case .userCancelled:
// User tapped "Cancel" in payment sheet
return false
case .pending:
// Purchase requires action (Ask to Buy, payment issue)
// Will be delivered via Transaction.updates when approved
return false
@unknown default:
return false
}
}
}
struct ProductRow: View {
let product: Product
@Environment(\.purchase) private var purchase
var body: some View {
Button("Buy \(product.displayPrice)") {
Task {
do {
let result = try await purchase(product)
// Handle result
} catch {
print("Purchase failed: \(error)")
}
}
}
}
}
func purchase(
_ product: Product,
confirmIn scene: UIWindowScene,
accountToken: UUID
) async throws -> Bool {
// Purchase with appAccountToken for server-side association
let result = try await product.purchase(
confirmIn: scene,
options: [
.appAccountToken(accountToken)
]
)
// ... handle result
}
When to use: When your backend needs to associate purchases with user accounts
func handleTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
// ✅ Transaction signed by App Store
await grantEntitlement(for: transaction)
await transaction.finish()
case .unverified(let transaction, let error):
// ❌ Transaction signature invalid
print("Unverified transaction: \(error)")
// DO NOT grant entitlement
// DO finish transaction to clear from queue
await transaction.finish()
}
}
Why verify: Prevents granting entitlements for:
func grantEntitlement(for transaction: Transaction) async {
// Check transaction hasn't been revoked
guard transaction.revocationDate == nil else {
print("Transaction was refunded")
await revokeEntitlement(for: transaction.productID)
return
}
// Grant based on product type
switch transaction.productType {
case .consumable:
await addConsumable(productID: transaction.productID)
case .nonConsumable:
await unlockFeature(productID: transaction.productID)
case .autoRenewable:
await activateSubscription(productID: transaction.productID)
default:
break
}
}
extension StoreManager {
func updatePurchasedProducts() async {
var purchased: Set<String> = []
// Iterate through all current entitlements
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue else {
continue
}
// Only include active entitlements (not revoked)
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
self.purchasedProductIDs = purchased
}
}
func isEntitled(to productID: String) async -> Bool {
// Check current entitlements for specific product
for await result in Transaction.currentEntitlements(for: productID) {
if let transaction = try? result.payloadValue,
transaction.revocationDate == nil {
return true
}
}
return false
}
extension StoreManager {
func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
// Get subscription statuses for group
guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
let status = result.first else {
return nil
}
return status.state
}
}
func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
switch status.state {
case .subscribed:
// User has active subscription
showSubscribedContent()
case .expired:
// Subscription expired - show win-back offer
showResubscribeOffer()
case .inGracePeriod:
// Billing issue - show payment update prompt
showUpdatePaymentPrompt()
case .inBillingRetryPeriod:
// Apple retrying payment - maintain access
showBillingRetryMessage()
case .revoked:
// Family Sharing access removed
removeAccess()
@unknown default:
break
}
}
struct SubscriptionView: View {
var body: some View {
SubscriptionStoreView(groupID: "pro_tier") {
// Marketing content
VStack {
Image("premium-icon")
Text("Unlock all features")
}
}
.subscriptionStoreControlStyle(.prominentPicker)
}
}
extension StoreManager {
func restorePurchases() async {
// Sync all transactions from App Store
try? await AppStore.sync()
// Update current entitlements
await updatePurchasedProducts()
}
}
struct SettingsView: View {
@StateObject private var store = StoreManager()
var body: some View {
Button("Restore Purchases") {
Task {
await store.restorePurchases()
}
}
}
}
App Store Requirement: Apps with IAP must provide restore functionality for non-consumables and subscriptions.
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
guard let transaction = try? result.payloadValue else {
return
}
// Check if transaction was refunded
if let revocationDate = transaction.revocationDate {
print("Transaction refunded on \(revocationDate)")
await revokeEntitlement(for: transaction.productID)
} else {
await grantEntitlement(for: transaction)
}
await transaction.finish()
}
}
protocol StoreProtocol {
func products(for ids: [String]) async throws -> [Product]
func purchase(_ product: Product) async throws -> PurchaseResult
}
// Production
final class StoreManager: StoreProtocol {
func products(for ids: [String]) async throws -> [Product] {
try await Product.products(for: ids)
}
}
// Testing
final class MockStore: StoreProtocol {
var mockProducts: [Product] = []
var mockPurchaseResult: PurchaseResult?
func products(for ids: [String]) async throws -> [Product] {
mockProducts
}
func purchase(_ product: Product) async throws -> PurchaseResult {
mockPurchaseResult ?? .userCancelled
}
}
@Test func testSuccessfulPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: Mock successful purchase
mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: Entitlement granted
#expect(result == true)
#expect(manager.purchasedProductIDs.contains("com.app.premium"))
}
@Test func testCancelledPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: User cancels
mockStore.mockPurchaseResult = .userCancelled
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: No entitlement granted
#expect(result == false)
#expect(manager.purchasedProductIDs.isEmpty)
}
// ❌ WRONG: Writing purchase code without .storekit file
let products = try await Product.products(for: productIDs)
// Can't test this without App Store Connect setup!
✅ Correct: Create .storekit file FIRST, test in Xcode, THEN implement.
// ❌ Less ideal: Write code, test in sandbox, add .storekit later
let products = try await Product.products(for: productIDs)
let result = try await product.purchase(confirmIn: scene)
// "I tested this in sandbox, it works! I'll add .storekit config later."
✅ Recommended: Create .storekit config first, then write code.
If you're in this situation: See "Already Wrote Code Before Creating .storekit Config?" section above for your options (A, B, or C).
Why .storekit-first is better:
Sandbox testing is valuable - it validates against real App Store infrastructure. But starting with .storekit makes sandbox testing easier because you've already validated product IDs locally.
// ❌ WRONG: Purchase calls scattered throughout app
Button("Buy") {
try await product.purchase() // In view 1
}
Button("Subscribe") {
try await subscriptionProduct.purchase() // In view 2
}
✅ Correct: All purchases through centralized StoreManager.
// ❌ WRONG: Never calling finish()
func handleTransaction(_ transaction: Transaction) {
grantEntitlement(for: transaction)
// Missing: await transaction.finish()
}
✅ Correct: ALWAYS call transaction.finish() after granting entitlement.
// ❌ WRONG: Using unverified transaction
for await transaction in Transaction.all {
grantEntitlement(for: transaction) // Unsafe!
}
✅ Correct: Always check VerificationResult before granting.
// ❌ WRONG: Only handling purchases in purchase() method
func purchase() {
let result = try await product.purchase()
// What about pending purchases, family sharing, restore?
}
✅ Correct: Listen to Transaction.updates for ALL transaction sources.
// ❌ WRONG: No restore button
// App Store will REJECT your app!
✅ Correct: Provide visible "Restore Purchases" button in settings.
Before marking IAP implementation complete, verify:
Run these searches to verify compliance:
# Check StoreKit configuration exists
find . -name "*.storekit"
# Check transaction.finish() is called
rg "transaction\.finish\(\)" --type swift
# Check VerificationResult usage
rg "VerificationResult" --type swift
# Check Transaction.updates listener
rg "Transaction\.updates" --type swift
# Check restore implementation
rg "AppStore\.sync|Transaction\.all" --type swift