# StoreKit 2 Interview: Subscription Management and Receipt Validation > Master iOS interview questions on StoreKit 2, subscription management, receipt validation, and in-app purchase implementation with practical Swift code examples. - Published: 2026-03-09 - Updated: 2026-03-31 - Author: SharpSkill - Tags: ios, storekit, in-app-purchase, swift, interview - Reading time: 14 min --- StoreKit 2 represents a fundamental shift in how in-app purchases are handled on iOS. Introduced with iOS 15, this modern framework dramatically simplifies the code required for subscription management and transaction validation. Technical iOS interviews regularly cover this topic to evaluate developers' expertise in app monetization. > **Key Interview Insight** > > StoreKit 2 leverages a native Swift API with async/await, eliminating the need for complex callbacks and client-side receipt validation. Interviewers value candidates who can articulate the fundamental differences from StoreKit 1. ## StoreKit 2 Architecture Overview StoreKit 2 is built on a modern architecture that takes full advantage of Swift Concurrency. Unlike StoreKit 1, all operations are asynchronous and use native Swift types. ### Fetching Available Products The first step involves loading products configured in App Store Connect. StoreKit 2 simplifies this operation with a declarative API. ```swift // ProductService.swift import StoreKit actor ProductService { // Product identifiers configured in App Store Connect private let productIdentifiers: Set = [ "com.app.subscription.monthly", "com.app.subscription.yearly", "com.app.premium.lifetime" ] // Product cache to avoid repeated network calls private var cachedProducts: [Product] = [] func loadProducts() async throws -> [Product] { // Return cache if available guard cachedProducts.isEmpty else { return cachedProducts } // Asynchronous request to the App Store let products = try await Product.products(for: productIdentifiers) cachedProducts = products return products } func product(for identifier: String) async throws -> Product? { let products = try await loadProducts() return products.first { $0.id == identifier } } } ``` Using an `actor` ensures thread-safety when managing the product cache. ### StoreKit 2 Product Types StoreKit 2 clearly distinguishes between different product types. Understanding this distinction is essential for interviews. ```swift // ProductType+Extensions.swift import StoreKit extension Product.ProductType { var displayName: String { switch self { case .consumable: // Consumable products (credits, lives, etc.) return "Consumable" case .nonConsumable: // Permanent purchases (feature unlock) return "Non-Consumable" case .autoRenewable: // Subscriptions with automatic renewal return "Auto-Renewable Subscription" case .nonRenewable: // Subscriptions without renewal (temporary pass) return "Non-Renewable Subscription" @unknown default: return "Unknown" } } } ``` Auto-renewable subscriptions represent the dominant business model for modern iOS applications. ## Handling Purchase Transactions The purchase process in StoreKit 2 becomes linear thanks to async/await. No more delegates and nested callbacks from StoreKit 1. ### Initiating a Purchase ```swift // PurchaseManager.swift import StoreKit actor PurchaseManager { enum PurchaseError: Error { case productNotFound case purchaseCancelled case purchasePending case verificationFailed case unknown } func purchase(_ product: Product) async throws -> Transaction { // Initiate purchase with system modal let result = try await product.purchase() switch result { case .success(let verification): // Verify the transaction signature let transaction = try checkVerification(verification) // Mark the transaction as finished await transaction.finish() return transaction case .userCancelled: // User cancelled the purchase modal throw PurchaseError.purchaseCancelled case .pending: // Purchase pending (parental approval, etc.) throw PurchaseError.purchasePending @unknown default: throw PurchaseError.unknown } } private func checkVerification(_ result: VerificationResult) throws -> T { switch result { case .verified(let safe): // Transaction signed and verified by the App Store return safe case .unverified(_, let error): // Invalid or corrupted signature throw PurchaseError.verificationFailed } } } ``` The `finish()` method is crucial: it signals to the App Store that content has been delivered to the user. > **Interview Alert** > > Never forget to call `transaction.finish()` after delivering content. An unfinished transaction will be restored on the next app launch, causing unexpected behavior. ### Listening for Background Transactions StoreKit 2 introduces `Transaction.updates`, an asynchronous stream that emits transactions occurring outside the app (renewals, family sharing purchases, etc.). ```swift // TransactionObserver.swift import StoreKit actor TransactionObserver { private var updateTask: Task? func startObserving() { // Cancel any previous observation updateTask?.cancel() updateTask = Task(priority: .background) { // Infinite stream of transaction updates for await result in Transaction.updates { do { let transaction = try self.checkVerification(result) // Process the transaction based on its type await self.handleTransaction(transaction) // Always finish the transaction await transaction.finish() } catch { // Log the error for debugging print("Transaction verification failed: \(error)") } } } } func stopObserving() { updateTask?.cancel() updateTask = nil } private func handleTransaction(_ transaction: Transaction) async { switch transaction.productType { case .autoRenewable: // Update subscription status await updateSubscriptionStatus(transaction) case .nonConsumable: // Unlock premium feature await unlockFeature(transaction.productID) default: break } } private func checkVerification(_ result: VerificationResult) throws -> T { switch result { case .verified(let safe): return safe case .unverified: throw PurchaseError.verificationFailed } } private func updateSubscriptionStatus(_ transaction: Transaction) async { // Implementation of status update } private func unlockFeature(_ productID: String) async { // Implementation of feature unlock } enum PurchaseError: Error { case verificationFailed } } ``` Transaction observation should start as soon as the app launches to avoid missing any updates. ## Common Interview Questions on StoreKit 2 Subscriptions iOS interviews often include specific questions about subscription management. Here are the most common ones with detailed answers. ### Question 1: How to Check Current Subscription Status? StoreKit 2 provides direct access to active entitlements via `Transaction.currentEntitlements`. ```swift // SubscriptionStatusChecker.swift import StoreKit struct SubscriptionStatusChecker { struct SubscriptionStatus { let isActive: Bool let expirationDate: Date? let willRenew: Bool let productID: String? } static func checkCurrentStatus() async -> SubscriptionStatus { // Retrieve all active transactions var latestTransaction: Transaction? for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { // Filter only subscriptions if transaction.productType == .autoRenewable { // Keep the most recent transaction if latestTransaction == nil || transaction.purchaseDate > latestTransaction!.purchaseDate { latestTransaction = transaction } } } } guard let transaction = latestTransaction else { return SubscriptionStatus( isActive: false, expirationDate: nil, willRenew: false, productID: nil ) } // Check if the subscription is still active let isActive = transaction.expirationDate ?? Date.distantPast > Date() // Retrieve renewal information let renewalInfo = await getRenewalInfo(for: transaction.productID) return SubscriptionStatus( isActive: isActive, expirationDate: transaction.expirationDate, willRenew: renewalInfo?.willAutoRenew ?? false, productID: transaction.productID ) } private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? { guard let product = try? await Product.products(for: [productID]).first, let subscription = product.subscription else { return nil } // Retrieve renewal status let status = try? await subscription.status.first return status?.renewalInfo } } ``` This approach provides accurate subscription state without server calls. ### Question 2: How to Handle Free Trials and Promotional Offers? StoreKit 2 simplifies access to offer information. Interviewers often test understanding of different offer types. ```swift // OfferManager.swift import StoreKit struct OfferManager { enum OfferType { case freeTrial(duration: String) case payAsYouGo(periods: Int, price: Decimal) case payUpFront(duration: String, price: Decimal) case none } static func getIntroductoryOffer(for product: Product) -> OfferType { guard let subscription = product.subscription, let offer = subscription.introductoryOffer else { return .none } switch offer.paymentMode { case .freeTrial: // Free trial period return .freeTrial(duration: formatPeriod(offer.period)) case .payAsYouGo: // Reduced price over multiple periods return .payAsYouGo( periods: offer.periodCount, price: offer.price ) case .payUpFront: // Single payment for a period return .payUpFront( duration: formatPeriod(offer.period), price: offer.price ) @unknown default: return .none } } static func checkEligibility(for product: Product) async -> Bool { // Check if user can benefit from the offer guard let subscription = product.subscription else { return false } // isEligibleForIntroOffer indicates if this is a new subscriber return await subscription.isEligibleForIntroOffer } private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String { let value = period.value switch period.unit { case .day: return "\(value) day\(value > 1 ? "s" : "")" case .week: return "\(value) week\(value > 1 ? "s" : "")" case .month: return "\(value) month\(value > 1 ? "s" : "")" case .year: return "\(value) year\(value > 1 ? "s" : "")" @unknown default: return "" } } } ``` The `isEligibleForIntroOffer` method is essential to avoid displaying unavailable offers. ### Question 3: How to Restore Previous Purchases? Purchase restoration is a mandatory feature according to App Store guidelines. ```swift // RestoreManager.swift import StoreKit actor RestoreManager { struct RestoreResult { let restoredProducts: [String] let activeSubscriptions: [String] } func restorePurchases() async throws -> RestoreResult { var restoredProducts: [String] = [] var activeSubscriptions: [String] = [] // Synchronize with the App Store try await AppStore.sync() // Iterate through all user transactions for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } switch transaction.productType { case .nonConsumable: // Restore permanent purchases restoredProducts.append(transaction.productID) case .autoRenewable: // Check if subscription is active if let expiration = transaction.expirationDate, expiration > Date() { activeSubscriptions.append(transaction.productID) } default: // Consumables are not restorable break } } return RestoreResult( restoredProducts: restoredProducts, activeSubscriptions: activeSubscriptions ) } } ``` `AppStore.sync()` forces synchronization with Apple servers, useful after reinstallation. ## Server-Side Receipt Validation Server validation remains recommended for applications with critical subscriptions. StoreKit 2 introduces JWS (JSON Web Signatures) for modern validation. ### Extracting Transaction Data for the Server ```swift // ServerValidation.swift import StoreKit struct TransactionPayload: Codable { let transactionID: String let originalTransactionID: String let productID: String let purchaseDate: Date let expirationDate: Date? let jwsRepresentation: String } actor ServerValidationService { private let apiEndpoint = "https://api.example.com/verify" func validateWithServer(_ transaction: Transaction) async throws -> Bool { // Create payload for the server let payload = TransactionPayload( transactionID: String(transaction.id), originalTransactionID: String(transaction.originalID), productID: transaction.productID, purchaseDate: transaction.purchaseDate, expirationDate: transaction.expirationDate, jwsRepresentation: transaction.jwsRepresentation ?? "" ) // Encode to JSON let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let jsonData = try encoder.encode(payload) // Send to server var request = URLRequest(url: URL(string: apiEndpoint)!) request.httpMethod = "POST" request.httpBody = jsonData request.setValue("application/json", forHTTPHeaderField: "Content-Type") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return false } // Decode server response let validationResponse = try JSONDecoder().decode( ValidationResponse.self, from: data ) return validationResponse.isValid } } struct ValidationResponse: Codable { let isValid: Bool let subscriptionStatus: String? } ``` The `jwsRepresentation` contains the cryptographic signature verifiable by the server. > **App Store Server API** > > Apple now provides the App Store Server API for server-side validation. This modern API replaces the deprecated verifyReceipt endpoint and offers advanced features like server-to-server notifications. ### Configuring Server Notifications Server Notifications V2 enable real-time event reception. ```swift // ServerNotificationTypes.swift enum NotificationType: String, Codable { case subscribed = "SUBSCRIBED" case didRenew = "DID_RENEW" case didFailToRenew = "DID_FAIL_TO_RENEW" case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS" case expired = "EXPIRED" case gracePeriodExpired = "GRACE_PERIOD_EXPIRED" case offerRedeemed = "OFFER_REDEEMED" case priceIncrease = "PRICE_INCREASE" case refund = "REFUND" case renewalExtended = "RENEWAL_EXTENDED" case revoke = "REVOKE" } struct ServerNotification: Codable { let notificationType: NotificationType let subtype: String? let data: NotificationData struct NotificationData: Codable { let bundleId: String let environment: String let signedTransactionInfo: String let signedRenewalInfo: String? } } ``` These notifications enable keeping subscription state synchronized with the backend. ## Error Handling and Edge Cases Interviews often test understanding of error scenarios and their proper handling. ### Common StoreKit 2 Errors ```swift // PurchaseErrorHandler.swift import StoreKit struct PurchaseErrorHandler { enum UserFacingError { case networkError case paymentFailed case notAuthorized case productNotAvailable case unknown var message: String { switch self { case .networkError: return "Connection failed. Please check your internet connection." case .paymentFailed: return "Payment failed. Please verify your payment information." case .notAuthorized: return "In-app purchases are disabled on this device." case .productNotAvailable: return "This product is not currently available." case .unknown: return "An unexpected error occurred." } } } static func handle(_ error: Error) -> UserFacingError { // Check if it's a StoreKit error if let storeKitError = error as? StoreKitError { switch storeKitError { case .networkError: return .networkError case .userCancelled: // Don't show error, user cancelled return .unknown case .notAvailableInStorefront: return .productNotAvailable case .notEntitled: return .notAuthorized default: return .unknown } } // Purchase errors if let purchaseError = error as? Product.PurchaseError { switch purchaseError { case .invalidQuantity: return .unknown case .productUnavailable: return .productNotAvailable case .purchaseNotAllowed: return .notAuthorized default: return .unknown } } return .unknown } } ``` Clear error handling improves user experience and simplifies debugging. ### Offline Mode Support ```swift // OfflineCapabilityManager.swift import StoreKit actor OfflineCapabilityManager { private let userDefaults = UserDefaults.standard private let entitlementsKey = "cached_entitlements" func cacheCurrentEntitlements() async { var entitlements: [String: Date] = [:] for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { entitlements[transaction.productID] = transaction.expirationDate } } // Save locally if let data = try? JSONEncoder().encode(entitlements) { userDefaults.set(data, forKey: entitlementsKey) } } func getCachedEntitlements() -> [String: Date] { guard let data = userDefaults.data(forKey: entitlementsKey), let entitlements = try? JSONDecoder().decode( [String: Date].self, from: data ) else { return [:] } // Filter expired entitlements let now = Date() return entitlements.filter { _, expiration in expiration > now } } func hasValidEntitlement(for productID: String) -> Bool { let cached = getCachedEntitlements() return cached[productID] != nil } } ``` Local caching maintains access to premium features even without connectivity. ## Production Best Practices ### Recommended Architecture ```swift // SubscriptionService.swift import StoreKit import Combine @MainActor final class SubscriptionService: ObservableObject { static let shared = SubscriptionService() @Published private(set) var products: [Product] = [] @Published private(set) var purchasedProductIDs: Set = [] @Published private(set) var subscriptionStatus: SubscriptionStatus = .none private var transactionObserver: Task? enum SubscriptionStatus { case none case active(expirationDate: Date) case expired case inGracePeriod } private init() { // Start transaction observation startTransactionObserver() // Load initial state Task { await loadProducts() await updateSubscriptionStatus() } } private func startTransactionObserver() { transactionObserver = Task.detached(priority: .background) { for await result in Transaction.updates { if case .verified(let transaction) = result { await transaction.finish() await self.updateSubscriptionStatus() } } } } func loadProducts() async { do { let productIDs = [ "com.app.subscription.monthly", "com.app.subscription.yearly" ] products = try await Product.products(for: productIDs) } catch { print("Failed to load products: \(error)") } } func purchase(_ product: Product) async throws { let result = try await product.purchase() switch result { case .success(let verification): if case .verified(let transaction) = verification { await transaction.finish() await updateSubscriptionStatus() } case .userCancelled: break case .pending: break @unknown default: break } } func updateSubscriptionStatus() async { var purchasedIDs: Set = [] var latestSubscription: Transaction? for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { purchasedIDs.insert(transaction.productID) if transaction.productType == .autoRenewable { if latestSubscription == nil || transaction.purchaseDate > latestSubscription!.purchaseDate { latestSubscription = transaction } } } } purchasedProductIDs = purchasedIDs if let subscription = latestSubscription, let expiration = subscription.expirationDate { if expiration > Date() { subscriptionStatus = .active(expirationDate: expiration) } else { subscriptionStatus = .expired } } else { subscriptionStatus = .none } } deinit { transactionObserver?.cancel() } } ``` This centralized architecture simplifies purchase state management throughout the application. ## Conclusion StoreKit 2 transforms in-app purchase management into a modern and secure development experience. Key points to remember for an interview: ✅ Native async/await API: no more complex delegates and callbacks from StoreKit 1 ✅ Automatic verification: `VerificationResult` handles cryptographic transaction validation ✅ `Transaction.updates`: asynchronous stream for background transactions ✅ `Transaction.currentEntitlements`: direct access to user's active entitlements ✅ Promotional offers: `isEligibleForIntroOffer` to check eligibility ✅ Server validation: JWS and App Store Server API for enhanced security ✅ `transaction.finish()`: mandatory after content delivery ✅ Offline handling: local entitlement caching for seamless experience --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/ios/storekit-2-subscriptions-receipts-validation-interview