Skip to main content
Follow these recommendations to build a robust in-app purchase experience with ZeroSettle.

SDK Configuration

Configure Early

Initialize the SDK as early as possible in your app’s lifecycle:
@main
struct YourApp: App {
    init() {
        // Configure before any views load
        ZeroSettleIAP.shared.configure(.init(
            publishableKey: "your_live_key"
        ))
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .zeroSettleIAPHandler()
        }
    }
}

Use Sandbox Keys for Development

Keep sandbox and live keys separate:
#if DEBUG
let publishableKey = "your_test_key"  // Sandbox - no real charges
#else
let publishableKey = "your_live_key"  // Live - real payments
#endif

ZeroSettleIAP.shared.configure(.init(publishableKey: publishableKey))

Entitlement Management

Restore on Every Launch

Always restore entitlements when your app launches to catch purchases made on other devices or missed callbacks:
struct ContentView: View {
    @State private var isLoading = true
    @State private var entitlements: [Entitlement] = []

    var body: some View {
        Group {
            if isLoading {
                ProgressView()
            } else {
                MainContent(entitlements: entitlements)
            }
        }
        .task {
            entitlements = (try? await ZeroSettleIAP.shared.restoreEntitlements(
                userId: Auth.currentUser.id
            )) ?? []
            isLoading = false
        }
    }
}

Cache Entitlements Locally

For offline access, cache entitlements to UserDefaults or Keychain:
class EntitlementCache {
    private let key = "cached_entitlements"

    func save(_ entitlements: [Entitlement]) {
        let data = try? JSONEncoder().encode(entitlements)
        UserDefaults.standard.set(data, forKey: key)
    }

    func load() -> [Entitlement] {
        guard let data = UserDefaults.standard.data(forKey: key),
              let entitlements = try? JSONDecoder().decode([Entitlement].self, from: data) else {
            return []
        }
        return entitlements
    }
}

// Use cached while fetching fresh
let cached = entitlementCache.load()
updateUI(cached)

let fresh = try await ZeroSettleIAP.shared.restoreEntitlements(userId: userId)
entitlementCache.save(fresh)
updateUI(fresh)

Check Expiration for Subscriptions

Subscriptions have an expiresAt date. Check it when validating access:
func hasActiveSubscription(_ entitlements: [Entitlement]) -> Bool {
    entitlements.contains { entitlement in
        guard entitlement.isActive else { return false }

        // For subscriptions, also check expiration
        if let expiresAt = entitlement.expiresAt {
            return expiresAt > Date()
        }

        // Lifetime purchases have no expiration
        return true
    }
}

Purchase Flow

Use the Payment Sheet

ZSPaymentSheet provides the best conversion rates because the user stays in your app:
struct PurchaseButton: View {
    @State private var showCheckout = false
    let product: ZSProduct

    var body: some View {
        Button("Buy \(product.displayName)\(product.webPrice.formatted)") {
            showCheckout = true
        }
        .zsPaymentSheet(
            isPresented: $showCheckout,
            product: product,
            userId: Auth.currentUser.id
        ) { result in
            switch result {
            case .success(let transaction):
                unlockProduct(transaction.productId)
            case .failure(let error):
                if let sheetError = error as? PaymentSheetError,
                   sheetError == .cancelled {
                    // User cancelled — don't show an error
                    return
                }
                showError(error)
            }
        }
    }
}

Preloading

The .zsPaymentSheet() modifier automatically preloads the PaymentIntent and WKWebView before presenting the sheet, so the checkout is fully rendered the moment it slides up. For the standalone ZSPaymentSheet init or UIKit’s present method, you can preload manually:
.task {
    preloaded = await ZSPaymentSheet.preload(
        productId: product.id,
        userId: currentUser.id
    )
}

Show Loading State

For Safari-based checkout, show feedback immediately since it opens an external browser:
struct SafariPurchaseButton: View {
    @ObservedObject var iap = ZeroSettleIAP.shared
    let product: ZSProduct

    var body: some View {
        Button {
            Task {
                try await iap.purchase(
                    productId: product.id,
                    userId: Auth.currentUser.id
                )
            }
        } label: {
            if iap.pendingCheckout {
                ProgressView()
            } else {
                Text("Buy \(product.displayName)")
            }
        }
        .disabled(iap.pendingCheckout)
    }
}

Handle All Delegate Callbacks

Implement all delegate methods to handle every outcome:
class PurchaseManager: ZeroSettleIAPDelegate {
    func zeroSettleIAPCheckoutDidBegin(productId: String) {
        // Optional: log analytics
        Analytics.track("checkout_started", productId: productId)
    }

    func zeroSettleIAPCheckoutDidComplete(transaction: ZSTransaction) {
        // Unlock content immediately
        unlockProduct(transaction.productId)

        // Show confirmation
        showToast("Purchase complete!")

        // Track conversion
        Analytics.track("purchase_completed", transaction: transaction)
    }

    func zeroSettleIAPCheckoutDidCancel(productId: String) {
        // User chose not to buy - don't show an error
        Analytics.track("checkout_cancelled", productId: productId)
    }

    func zeroSettleIAPCheckoutDidFail(productId: String, error: Error) {
        // Something went wrong
        showError("Purchase failed. Please try again.")
        Analytics.track("checkout_failed", error: error)
    }

    func zeroSettleIAPEntitlementsDidUpdate(_ entitlements: [Entitlement]) {
        // Sync UI with current entitlements
        updatePremiumUI(entitlements)
    }

    func zeroSettleIAPDidSyncStoreKitTransaction(productId: String, transactionId: UInt64) {
        // StoreKit transaction synced to ZeroSettle
        Analytics.track("storekit_synced", productId: productId)
    }

    func zeroSettleIAPStoreKitSyncFailed(error: Error) {
        // StoreKit sync failed — will retry automatically
        print("StoreKit sync failed: \(error)")
    }
}

Provide a Restore Button

Apple requires a way for users to restore purchases. Add a visible restore option:
Button("Restore Purchases") {
    Task {
        do {
            let entitlements = try await ZeroSettleIAP.shared.restoreEntitlements(
                userId: Auth.currentUser.id
            )

            if entitlements.isEmpty {
                showMessage("No purchases found")
            } else {
                showMessage("Purchases restored!")
            }
        } catch {
            showError("Restore failed: \(error.localizedDescription)")
        }
    }
}

User Identity

Use Stable Identifiers

Choose a user ID that won’t change:
// Good: Backend-generated UUID
userId: user.id  // "usr_abc123"

// Good: Firebase UID
userId: Auth.auth().currentUser?.uid ?? ""

// Bad: Email (users can change it)
userId: user.email

// Bad: Device identifier (changes on reinstall)
userId: UIDevice.current.identifierForVendor?.uuidString ?? ""

Handle Logged-Out State

If your app supports logged-out usage, decide how to handle purchases:
func purchase(product: ZSProduct) async throws {
    guard let user = Auth.currentUser else {
        // Option 1: Require login first
        showLoginPrompt()
        return

        // Option 2: Use anonymous purchase (email from checkout)
        // try await ZeroSettleIAP.shared.purchase(productId: product.id, userId: "")
    }

    try await ZeroSettleIAP.shared.purchase(
        productId: product.id,
        userId: user.id
    )
}

Error Handling

Handle Each Error Type

ZeroSettle uses specific error types for different scenarios:
Error TypeWhen It’s Thrown
ZeroSettleIAPErrorSDK configuration and general errors
PaymentSheetErrorPayment sheet presentation and payment errors
StoreKitPurchaseErrorStoreKit purchase errors
CheckoutErrorLegacy checkout view errors (deprecated)
do {
    try await ZeroSettleIAP.shared.purchase(productId: id, userId: userId)
} catch let error as ZeroSettleIAPError {
    switch error {
    case .notConfigured:
        fatalError("SDK not configured")
    case .productNotFound(let id):
        print("Product \(id) not found")
    case .networkError(let underlying):
        showRetry("Network error: \(underlying.localizedDescription)")
    default:
        showError(error.localizedDescription)
    }
}

Graceful Degradation

If the SDK fails to initialize or fetch products, fall back gracefully:
struct PaywallView: View {
    @State private var products: [Product] = []
    @State private var loadError: Error?

    var body: some View {
        Group {
            if let error = loadError {
                VStack {
                    Text("Unable to load products")
                    Button("Retry") { loadProducts() }
                }
            } else if products.isEmpty {
                ProgressView()
            } else {
                ProductList(products: products)
            }
        }
        .task { loadProducts() }
    }

    func loadProducts() {
        Task {
            do {
                products = try await ZeroSettleIAP.shared.fetchProducts(
                    userId: Auth.currentUser.id
                )
                loadError = nil
            } catch {
                loadError = error
            }
        }
    }
}

Testing

Test the Full Flow

Before releasing, test these scenarios on a physical device:
ScenarioExpected Behavior
Complete purchase (payment sheet)Transaction returned, entitlements update
Complete purchase (Safari)Universal link callback, entitlements update
Cancel payment sheet.cancelled error, no entitlement change
Cancel Safari checkoutNo entitlement change, no error shown
Restore purchasesAll previous purchases recovered
App killed during checkoutEntitlements sync on next launch
Network offlineCached entitlements still work
Different device, same accountEntitlements sync via restore
StoreKit purchase (hybrid flow)Transaction synced to ZeroSettle

Use Sandbox Keys

Test with sandbox keys before going live:
ZeroSettleIAP.shared.configure(.init(publishableKey: "your_test_key"))

Performance

Fetch Products Once

Don’t fetch products on every view appearance. Fetch once and cache:
class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    private var hasFetched = false

    func fetchIfNeeded(userId: String) async {
        guard !hasFetched else { return }
        hasFetched = true

        products = (try? await ZeroSettleIAP.shared.fetchProducts(userId: userId)) ?? []
    }
}

Prefetch Products

Fetch products before the user reaches the paywall:
struct HomeView: View {
    var body: some View {
        TabView {
            // ...
        }
        .task {
            // Prefetch while user is on home screen
            _ = try? await ZeroSettleIAP.shared.fetchProducts(userId: Auth.currentUser.id)
        }
    }
}

Security

Never Trust Client-Side Entitlements Alone

For critical features, verify entitlements server-side:
// Client: User claims to have premium
// Server: Verify with ZeroSettle API before granting access

// Your backend calls ZeroSettle
GET https://api.zerosettle.io/v1/iap/entitlements?user_id=<user_id>
X-ZeroSettle-Key: <secret_key>

Keep Secret Keys Server-Side

Only use publishable keys in your app. Secret keys belong on your server:
Key TypeWhere to Use
PublishableiOS app
SecretYour backend only