Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

iOS App Store Deployment and StoreKit 2

May 23, 2025 · 10 min read

iOS, App Store, StoreKit, Subscriptions, Interview Prep

Introduction

Publishing to the App Store is the final step of iOS development, but it involves more than clicking "Submit." Understanding code signing, implementing in-app purchases with StoreKit 2, and navigating App Review are skills interviewers expect from experienced iOS developers.

Launching apps with subscription models taught me the nuances of StoreKit and what reviewers look for. Here's what matters.


Code Signing Fundamentals

// Xcode handles most signing automatically with:
// Signing & Capabilities → Automatically manage signing

// Manual signing requires:
// 1. Development Certificate (for testing)
// 2. Distribution Certificate (for App Store)
// 3. Provisioning Profiles (links app ID, certificate, and devices)

// App ID format: Team ID + Bundle ID
// Example: ABCD1234.com.yourcompany.app

// Capabilities must be enabled in both:
// - Xcode (Signing & Capabilities tab)
// - App ID in Apple Developer Portal

Automatic vs Manual Signing

Use automatic signing for most projects - it handles certificate and profile management for you. Manual signing is needed when you have specific requirements like multiple distribution certificates or complex CI/CD setups.


StoreKit 2 - Modern In-App Purchases

Product Setup

import StoreKit

// Product identifiers (configured in App Store Connect)
enum ProductID: String, CaseIterable {
    case monthlyPremium = "com.yourapp.premium.monthly"
    case yearlyPremium = "com.yourapp.premium.yearly"
    case lifetime = "com.yourapp.premium.lifetime"
}

@Observable
class StoreManager {
    static let shared = StoreManager()

    var products: [Product] = []
    var purchasedProductIDs: Set<String> = []
    var isLoading = false

    var isPremium: Bool {
        !purchasedProductIDs.isEmpty
    }

    init() {
        Task {
            await loadProducts()
            await updatePurchasedProducts()
            await listenForTransactions()
        }
    }

    // Load available products
    func loadProducts() async {
        isLoading = true

        do {
            let productIDs = ProductID.allCases.map { $0.rawValue }
            products = try await Product.products(for: Set(productIDs))
            products.sort { $0.price < $1.price }
        } catch {
            print("Failed to load products: \(error)")
        }

        isLoading = false
    }
}

Handling Purchases

extension StoreManager {
    // Purchase a product
    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updatePurchasedProducts()
            await transaction.finish()
            return transaction

        case .userCancelled:
            return nil

        case .pending:
            // Ask to Pay Later or parental approval needed
            return nil

        @unknown default:
            return nil
        }
    }

    // Verify transaction
    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.verificationFailed
        case .verified(let safe):
            return safe
        }
    }

    // Update purchased status
    func updatePurchasedProducts() async {
        var purchased: Set<String> = []

        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }

            if transaction.revocationDate == nil {
                purchased.insert(transaction.productID)
            }
        }

        await MainActor.run {
            purchasedProductIDs = purchased
        }
    }

    // Listen for transaction updates
    private func listenForTransactions() async {
        for await result in Transaction.updates {
            guard case .verified(let transaction) = result else { continue }

            await updatePurchasedProducts()
            await transaction.finish()
        }
    }

    // Restore purchases (required by App Store)
    func restorePurchases() async throws {
        try await AppStore.sync()
        await updatePurchasedProducts()
    }
}

enum StoreError: LocalizedError {
    case verificationFailed
    case purchaseFailed

    var errorDescription: String? {
        switch self {
        case .verificationFailed: return "Purchase verification failed"
        case .purchaseFailed: return "Purchase could not be completed"
        }
    }
}

StoreKit 2 vs StoreKit 1

StoreKit 2 dramatically simplifies in-app purchases with async/await APIs and automatic transaction verification. Transaction.currentEntitlements eliminates manual receipt parsing. For new projects targeting iOS 15+, always use StoreKit 2.


Subscription UI (Paywall)

struct PaywallView: View {
    @Environment(StoreManager.self) var store
    @State private var selectedProduct: Product?
    @State private var isPurchasing = false
    @State private var errorMessage: String?

    var body: some View {
        VStack(spacing: 24) {
            // Header
            VStack(spacing: 8) {
                Text("Unlock Premium")
                    .font(.largeTitle.bold())
                Text("Get unlimited access to all features")
                    .foregroundColor(.secondary)
            }

            // Features list
            VStack(alignment: .leading, spacing: 12) {
                FeatureRow(icon: "chart.bar.fill", text: "Advanced Analytics")
                FeatureRow(icon: "icloud.fill", text: "Cloud Sync")
                FeatureRow(icon: "camera.fill", text: "Unlimited Photos")
                FeatureRow(icon: "bell.fill", text: "Smart Reminders")
            }
            .padding()

            // Product options
            VStack(spacing: 12) {
                ForEach(store.products) { product in
                    ProductButton(
                        product: product,
                        isSelected: selectedProduct?.id == product.id
                    ) {
                        selectedProduct = product
                    }
                }
            }

            // Purchase button
            Button {
                Task { await purchase() }
            } label: {
                if isPurchasing {
                    ProgressView()
                        .tint(.white)
                } else {
                    Text("Continue")
                        .bold()
                }
            }
            .frame(maxWidth: .infinity)
            .padding()
            .background(selectedProduct != nil ? Color.blue : Color.gray)
            .foregroundColor(.white)
            .cornerRadius(12)
            .disabled(selectedProduct == nil || isPurchasing)

            // Restore purchases (REQUIRED by App Store)
            Button("Restore Purchases") {
                Task {
                    try? await store.restorePurchases()
                }
            }
            .font(.footnote)

            // Legal text
            Text("Payment will be charged to your Apple ID. Subscriptions auto-renew unless cancelled at least 24 hours before the end of the current period.")
                .font(.caption)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
        }
        .padding()
    }

    private func purchase() async {
        guard let product = selectedProduct else { return }

        isPurchasing = true

        do {
            _ = try await store.purchase(product)
        } catch {
            errorMessage = error.localizedDescription
        }

        isPurchasing = false
    }
}

Handling Subscription Status

extension StoreManager {
    var subscriptionStatus: SubscriptionStatus {
        get async {
            guard let product = products.first(where: { $0.type == .autoRenewable }),
                  let status = try? await product.subscription?.status.first else {
                return .notSubscribed
            }

            switch status.state {
            case .subscribed:
                return .active
            case .expired:
                return .expired
            case .inBillingRetryPeriod:
                return .inGracePeriod
            case .inGracePeriod:
                return .inGracePeriod
            case .revoked:
                return .revoked
            default:
                return .notSubscribed
            }
        }
    }

    func checkTrialEligibility(for product: Product) async -> Bool {
        guard let subscription = product.subscription else { return false }
        return await subscription.isEligibleForIntroOffer
    }
}

enum SubscriptionStatus {
    case notSubscribed
    case active
    case expired
    case inGracePeriod
    case revoked
}

// Gating premium features
struct PremiumFeatureView: View {
    @Environment(StoreManager.self) var store

    var body: some View {
        if store.isPremium {
            AdvancedAnalyticsView()
        } else {
            VStack {
                Text("This feature requires Premium")
                Button("Upgrade") {
                    // Show paywall
                }
            }
        }
    }
}

TestFlight and Environment Detection

// Check if running in TestFlight
extension Bundle {
    var isTestFlight: Bool {
        guard let path = appStoreReceiptURL?.path else { return false }
        return path.contains("sandboxReceipt")
    }
}

// Configuration for different environments
enum Environment {
    static var current: EnvironmentType {
        #if DEBUG
        return .development
        #else
        return Bundle.main.isTestFlight ? .testFlight : .production
        #endif
    }
}

enum EnvironmentType {
    case development
    case testFlight
    case production

    var apiBaseURL: URL {
        switch self {
        case .development:
            return URL(string: "http://localhost:5000")!
        case .testFlight:
            return URL(string: "https://api-staging.yourapp.com")!
        case .production:
            return URL(string: "https://api.yourapp.com")!
        }
    }
}

App Store Connect Checklist

/*
Pre-Launch Checklist:

1. Code Signing
   □ Distribution certificate created
   □ App ID with required capabilities
   □ Provisioning profile for App Store

2. App Store Connect
   □ App record created
   □ All metadata filled
   □ Screenshots for all required sizes
   □ Privacy policy URL (required)
   □ In-app purchases configured
   □ Subscription groups set up

3. Testing
   □ TestFlight build uploaded
   □ Internal testing complete
   □ StoreKit testing with sandbox accounts

4. Legal
   □ Privacy policy
   □ Terms of service
   □ Data deletion compliance (GDPR, CCPA)

5. Technical
   □ Crash reporting configured
   □ Analytics enabled
   □ Background modes reviewed
   □ Push notification certificates

6. Submission
   □ Build uploaded via Xcode or Transporter
   □ Export compliance questions answered
   □ Advertising ID usage declared
   □ Submit for review
*/

App Review Tips

Include detailed reviewer notes explaining how to test subscriptions (provide sandbox account credentials if needed), any special features requiring demo mode, and clear screenshots of your onboarding flow. Clear communication speeds up review.


Interview Questions

Q1: How does StoreKit 2 differ from StoreKit 1?

Answer:

AspectStoreKit 1StoreKit 2
API StyleDelegate callbacksAsync/await
Transaction HandlingSKPaymentQueueTransaction.updates
VerificationManual receipt parsingAutomatic verification
EntitlementsManual trackingTransaction.currentEntitlements
Subscription StatusParse receiptproduct.subscription.status
// StoreKit 1 - Complex delegate pattern
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for transaction in transactions {
        switch transaction.transactionState {
        case .purchased:
            // Validate receipt manually
            // Update UI
            // Finish transaction
        // ... more cases
        }
    }
}

// StoreKit 2 - Simple async/await
let result = try await product.purchase()
if case .success(let verification) = result {
    let transaction = try checkVerified(verification)
    await transaction.finish()
}

Q2: How do you test in-app purchases?

Answer: Multiple testing methods:

  1. StoreKit Testing in Xcode:

    • Create StoreKit Configuration file
    • Test without App Store Connect
    • Control subscription timing
    • Simulate failures
  2. Sandbox Testing:

    • Create sandbox testers in App Store Connect
    • Sign out of real account on device
    • Sign in with sandbox account
    • Purchases don't charge money
  3. TestFlight:

    • Uses sandbox environment
    • Real users, real flow
    • Subscriptions renew quickly (5 min = 1 month)

Q3: What happens when a subscription expires?

Answer: Handle gracefully:

let status = await store.subscriptionStatus

switch status {
case .active:
    // Full access
case .expired:
    // Show renewal prompt, downgrade features
case .inGracePeriod:
    // Still provide access, show warning
case .revoked:
    // Access revoked (refund), remove access
}

Best practices:

  • Check status on app launch
  • Listen for Transaction.updates
  • Provide grace period UX
  • Don't lock users out abruptly

Q4: What are common App Review rejection reasons?

Answer:

  1. Missing Restore Purchases - Required for all apps with IAP
  2. Broken links - Privacy policy, support URLs must work
  3. Incomplete metadata - Screenshots, descriptions
  4. Misleading features - Don't promise features you don't have
  5. Crashes - Test thoroughly on multiple devices
  6. Private API usage - Only use public APIs
  7. Inadequate login options - Sign in with Apple required if you have social login

Common Mistakes

1. Not Finishing Transactions

// ❌ Transaction stays in queue, causes issues
let result = try await product.purchase()
if case .success(let verification) = result {
    let transaction = try checkVerified(verification)
    // Forgot to finish!
}

// ✅ Always finish transactions
let transaction = try checkVerified(verification)
// ... process purchase ...
await transaction.finish()

2. Not Handling All Purchase States

// ❌ Only handles success
if case .success = result { ... }

// ✅ Handle all cases
switch result {
case .success(let verification):
    // Process purchase
case .userCancelled:
    // User cancelled - don't show error
case .pending:
    // Ask to Pay Later or parental approval
    // Show appropriate message
@unknown default:
    break
}

3. Missing Restore Purchases

// ❌ App Review rejection!
// No restore button

// ✅ Required by App Store Guidelines
Button("Restore Purchases") {
    Task {
        try await AppStore.sync()
        await store.updatePurchasedProducts()
    }
}

4. Blocking UI During Purchase

// ❌ UI appears frozen
let result = try await product.purchase()

// ✅ Show loading state
isPurchasing = true
Task {
    let result = try await product.purchase()
    isPurchasing = false
}

Summary

TopicKey Points
Code SigningCertificates + Provisioning Profiles
StoreKit 2Async/await, automatic verification
TransactionsAlways finish, handle all states
RestoreRequired by App Store
TestingSandbox accounts, StoreKit config
SubscriptionsCheck currentEntitlements
ReviewClear metadata, reviewer notes

The App Store is the finish line, but crossing it requires attention to detail. Use StoreKit 2 for cleaner code, always implement restore purchases, and communicate clearly with reviewers.


Part 10 of the iOS Interview Prep series. This completes the iOS Interview Prep series covering everything from SwiftUI fundamentals to App Store deployment.

← Previous

Event Sourcing Series Part 1: The Honest Truth

Next →

iOS Testing and Debugging Essentials