---
name: axiom-in-app-purchases
description: 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
license: MIT
metadata:
  version: "1.0"
---

# StoreKit 2 In-App Purchase Implementation

**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

## When to Use This Skill

✅ **Use this skill when**:
- Implementing any in-app purchase functionality (new or existing)
- Adding consumable products (coins, hints, boosts)
- Adding non-consumable products (premium features, level packs)
- Adding auto-renewable subscriptions (monthly/annual plans)
- Debugging purchase failures, missing transactions, or restore issues
- Setting up StoreKit testing configuration
- Implementing subscription status tracking
- Adding promotional offers or introductory offers
- Server-side receipt validation
- Family Sharing support

❌ **Do NOT use this skill for**:
- StoreKit 1 (legacy API) - this skill focuses on StoreKit 2
- App Store Connect product configuration (separate documentation)
- Pricing strategy or business model decisions

---

## ⚠️ Already Wrote Code Before Creating .storekit Config?

If you wrote purchase code before creating `.storekit` configuration, you have three options:

### Option A: Delete and Start Over (Strongly Recommended)

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**:
- Validates that you understand the workflow
- Catches product ID issues you might have missed
- Builds muscle memory for future IAP implementations
- Takes only 15-30 minutes for experienced developers

### Option B: Create .storekit Config Now (Acceptable with Caution)

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**:
- ✅ Keeps working code
- ✅ Adds local testing capability
- ❌ Misses product ID validation benefit
- ❌ Reinforces testing-after pattern
- ❌ Requires extra vigilance in code review

**If choosing this path**: Create .storekit immediately, verify locally, and commit a note explaining the approach.

### Option C: Skip .storekit Entirely (Not Recommended)

Commit without `.storekit` configuration, test only in sandbox.

**Why this is problematic**:
- Teammates can't test purchases locally
- No validation of product IDs before runtime
- Harder iteration (requires App Store Connect)
- Missing documentation of product structure

**Bottom line**: Choose Option A if possible, Option B if pragmatic, never Option C.

---

## Core Philosophy: Testing-First Workflow

> **Best Practice**: Create and test StoreKit configuration BEFORE writing production purchase code.

### Why .storekit-First Matters

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**:
- Typos caught in Xcode, not at runtime
- Product configuration visible in project
- No App Store Connect dependency for testing

**Faster iteration**:
- Test purchases in simulator instantly
- No network requests during development
- Accelerated subscription renewal for testing

**Team benefits**:
- Anyone can test purchase flows locally
- Product catalog documented in code
- Code review includes purchase testing

**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.

### The Recommended Workflow

```
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
      ↓               ↓                ↓               ↓              ↓
   .storekit      Test purchases   StoreManager    Mock store    Integration test
```

**Why this order helps**:
1. **StoreKit Config First**: Defines products without App Store Connect dependency
2. **Local Testing**: Validates product IDs and purchase flows immediately
3. **Production Code**: Implements against validated product configuration
4. **Unit Tests**: Verifies business logic with mocked store responses
5. **Sandbox Testing**: Final validation in App Store environment

**Benefits of following this workflow**:
- Product IDs validated before writing code
- Faster development iteration
- Easier team collaboration
- Better test coverage

---

## Mandatory Checklist

Before marking IAP implementation complete, **ALL** items must be verified:

### Phase 1: Testing Foundation
- [ ] Created `.storekit` configuration file with all products
- [ ] Verified each product type renders correctly in StoreKit preview
- [ ] Tested successful purchase flow for each product in Xcode
- [ ] Tested purchase failure scenarios (insufficient funds, cancelled)
- [ ] Tested restore purchases flow
- [ ] For subscriptions: tested renewal, expiration, and upgrade/downgrade

### Phase 2: Architecture
- [ ] Centralized StoreManager class exists (single source of truth)
- [ ] StoreManager is ObservableObject (SwiftUI) or uses NotificationCenter
- [ ] Transaction observer listens for updates via `Transaction.updates`
- [ ] All transaction verification uses `VerificationResult`
- [ ] All transactions call `.finish()` after entitlement granted
- [ ] Product loading happens at app launch or before displaying store

### Phase 3: Purchase Flow
- [ ] Purchase uses new `purchase(confirmIn:options:)` with UI context (iOS 18.2+)
- [ ] Purchase handles all `PurchaseResult` cases (success, userCancelled, pending)
- [ ] Purchase verifies transaction signature before granting entitlement
- [ ] Purchase stores transaction receipt/identifier for support
- [ ] appAccountToken set for all purchases (if using server backend)

### Phase 4: Subscription Management (if applicable)
- [ ] Subscription status tracked via `Product.SubscriptionInfo.Status`
- [ ] Current entitlements checked via `Transaction.currentEntitlements(for:)`
- [ ] Renewal info accessed for expiration, renewal date, offer status
- [ ] Subscription views use ProductView or SubscriptionStoreView
- [ ] Win-back offers implemented for expired subscriptions
- [ ] Grace period and billing retry states handled

### Phase 5: Restore & Sync
- [ ] Restore purchases implemented (required by App Store Review)
- [ ] Restore uses `Transaction.currentEntitlements` or `Transaction.all`
- [ ] Family Sharing transactions identified (if supported)
- [ ] Server sync implemented (if using backend)
- [ ] Cross-device entitlement sync tested

### Phase 6: Error Handling
- [ ] Network errors handled gracefully (retries, user messaging)
- [ ] Invalid product IDs detected and logged
- [ ] Purchase failures show user-friendly error messages
- [ ] Transaction verification failures logged and reported
- [ ] Refund notifications handled (via App Store Server Notifications)

### Phase 7: Testing & Validation
- [ ] Unit tests verify purchase logic with mocked Product/Transaction
- [ ] Unit tests verify subscription status determination
- [ ] Integration tests with StoreKit configuration pass
- [ ] Sandbox testing with real Apple ID completed
- [ ] TestFlight testing completed before production release

---

## Step 1: Create StoreKit Configuration (FIRST!)

**DO THIS BEFORE WRITING ANY PURCHASE CODE.**

### Create Configuration File

1. **Xcode → File → New → File → StoreKit Configuration File**
2. **Save as**: `Products.storekit` (or your app name)
3. **Add to target**: ✅ (include in app bundle for testing)

### Add Products

Click "+" and add each product type:

#### Consumable
```
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
```

#### Non-Consumable
```
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
```

#### Auto-Renewable Subscription
```
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
```

### Test Immediately

1. **Run app in simulator**
2. **Scheme → Edit Scheme → Run → Options**
3. **StoreKit Configuration**: Select `Products.storekit`
4. **Verify**: Products load, purchases complete, transactions appear

---

## Step 2: Implement StoreManager Architecture

### Required Pattern: Centralized StoreManager

**All purchase logic must go through a single StoreManager.** No scattered `Product.purchase()` calls throughout app.

```swift
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.

### Load Products (At Launch)

```swift
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

### Listen for Transactions (REQUIRED)

```swift
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

---

## Step 3: Implement Purchase Flow

### Purchase with UI Context (iOS 18.2+)

```swift
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
        }
    }
}
```

### SwiftUI Purchase (Using Environment)

```swift
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)")
                }
            }
        }
    }
}
```

### Set appAccountToken (If Using Backend)

```swift
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

---

## Step 4: Verify Transactions (MANDATORY)

### Always Use VerificationResult

```swift
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:
- Fraudulent receipts
- Jailbroken device receipts
- Man-in-the-middle attacks

### Check Transaction Fields

```swift
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
    }
}
```

---

## Step 5: Track Current Entitlements

### Check What User Owns

```swift
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
    }
}
```

### Check Specific Product

```swift
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
}
```

---

## Step 6: Implement Subscription Management

### Track Subscription Status

```swift
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
    }
}
```

### Handle Subscription States

```swift
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
    }
}
```

### Use StoreKit Views (iOS 17+)

```swift
struct SubscriptionView: View {
    var body: some View {
        SubscriptionStoreView(groupID: "pro_tier") {
            // Marketing content
            VStack {
                Image("premium-icon")
                Text("Unlock all features")
            }
        }
        .subscriptionStoreControlStyle(.prominentPicker)
    }
}
```

---

## Step 7: Implement Restore Purchases (REQUIRED)

### Restore Flow

```swift
extension StoreManager {
    func restorePurchases() async {
        // Sync all transactions from App Store
        try? await AppStore.sync()

        // Update current entitlements
        await updatePurchasedProducts()
    }
}
```

### UI Button

```swift
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.

---

## Step 8: Handle Refunds

### Listen for Refund Notifications

```swift
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()
    }
}
```

---

## Step 9: Unit Testing

### Mock Store Responses

```swift
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 Purchase Logic

```swift
@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)
}
```

---

## Common Anti-Patterns (NEVER DO THIS)

### ❌ No StoreKit Configuration

```swift
// ❌ 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.

### ❌ Code Before .storekit Config

```swift
// ❌ 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**:
- Product ID typos caught in Xcode, not at runtime
- Faster iteration without network requests
- Teammates can test locally
- Documents product structure in code

**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.

### ❌ Scattered Purchase Calls

```swift
// ❌ 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.

### ❌ Forgetting to Finish Transactions

```swift
// ❌ WRONG: Never calling finish()
func handleTransaction(_ transaction: Transaction) {
    grantEntitlement(for: transaction)
    // Missing: await transaction.finish()
}
```

✅ **Correct**: ALWAYS call `transaction.finish()` after granting entitlement.

### ❌ Not Verifying Transactions

```swift
// ❌ WRONG: Using unverified transaction
for await transaction in Transaction.all {
    grantEntitlement(for: transaction)  // Unsafe!
}
```

✅ **Correct**: Always check `VerificationResult` before granting.

### ❌ Ignoring Transaction Listener

```swift
// ❌ 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.

### ❌ Not Implementing Restore

```swift
// ❌ WRONG: No restore button
// App Store will REJECT your app!
```

✅ **Correct**: Provide visible "Restore Purchases" button in settings.

---

## Validation

Before marking IAP implementation complete, verify:

### Code Inspection

Run these searches to verify compliance:

```bash
# 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
```

### Functional Testing

- [ ] Can purchase each product type in StoreKit configuration
- [ ] Can cancel purchase and state remains consistent
- [ ] Can restore purchases and regain access
- [ ] Subscription renewal/expiration works as expected
- [ ] Refunded transactions revoke access
- [ ] Family Sharing transactions identified (if supported)

### Sandbox Testing

- [ ] Real Apple ID sandbox purchases complete
- [ ] TestFlight beta testers confirm purchase flows work
- [ ] Server-side validation works (if using backend)

### App Store Connect Submission (see **app-store-submission** for full checklist)

- [ ] **Review screenshot uploaded** for each IAP product (shows purchase UI — review-only, not on App Store)
- [ ] **IAP products attached to this version** (first submission: App Version → In-App Purchases section → Select → checkbox each product)
- [ ] **Terms of Use + Privacy Policy links on purchase screen** (required by DPLA Schedule 2; `SubscriptionStoreView` handles this automatically)
- [ ] Subscription terms explicit: price, period, auto-renewal, cancellation

---

## Resources

**WWDC**: 2025-241, 2025-249, 2023-10013, 2021-10114

**Docs**: /storekit, /appstoreserverapi

**Skills**: axiom-storekit-ref
