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:
| Aspect | StoreKit 1 | StoreKit 2 |
|---|---|---|
| API Style | Delegate callbacks | Async/await |
| Transaction Handling | SKPaymentQueue | Transaction.updates |
| Verification | Manual receipt parsing | Automatic verification |
| Entitlements | Manual tracking | Transaction.currentEntitlements |
| Subscription Status | Parse receipt | product.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:
-
StoreKit Testing in Xcode:
- Create StoreKit Configuration file
- Test without App Store Connect
- Control subscription timing
- Simulate failures
-
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
-
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:
- Missing Restore Purchases - Required for all apps with IAP
- Broken links - Privacy policy, support URLs must work
- Incomplete metadata - Screenshots, descriptions
- Misleading features - Don't promise features you don't have
- Crashes - Test thoroughly on multiple devices
- Private API usage - Only use public APIs
- 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
| Topic | Key Points |
|---|---|
| Code Signing | Certificates + Provisioning Profiles |
| StoreKit 2 | Async/await, automatic verification |
| Transactions | Always finish, handle all states |
| Restore | Required by App Store |
| Testing | Sandbox accounts, StoreKit config |
| Subscriptions | Check currentEntitlements |
| Review | Clear 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.