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. On iOS, call configure() early, then bootstrap() to fetch products and restore entitlements. On Android, configure() is synchronous and should be called in Application.onCreate(), then call bootstrap() to fetch products and restore entitlements:
@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    ZeroSettle.shared.configure(.init(
                        publishableKey: "your_live_key"
                    ))
                    try? await ZeroSettle.shared.bootstrap(userId: Auth.currentUser.id)
                }
                .zeroSettleHandler()
        }
    }
}

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

ZeroSettle.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 ZeroSettle.shared.restoreEntitlements(
                userId: Auth.currentUser.id
            )) ?? []
            isLoading = false
        }
    }
}

Cache Entitlements Locally

For offline access, cache entitlements to UserDefaults (iOS) or SharedPreferences (Android):
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 ZeroSettle.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

On iOS, checkoutSheet provides the best conversion rates because the user stays in your app. On Android, use ZeroSettle.purchase() which opens a Custom Tab for the web checkout:
struct PurchaseButton: View {
    @State private var selectedProduct: Product?
    let product: ZSProduct

    var body: some View {
        Button("Buy \(product.displayName)\(product.webPrice.formatted)") {
            selectedProduct = product
        }
        .checkoutSheet(
            item: $selectedProduct,
            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 & Caching

Preloading and warmUp() are iOS-specific APIs. On Android, products are cached in the ZeroSettle.products StateFlow after calling bootstrap() or fetchProducts(). No additional preloading step is needed.
The .checkoutSheet() modifier automatically preloads the PaymentIntent and WKWebView before presenting the sheet, so the checkout is fully rendered the moment it slides up. PaymentIntent results are cached for 5 minutes. This means re-opens after dismiss skip the API call entirely. The cache is invalidated automatically after a successful payment. For the fastest first open, call warmUp() right after fetching products:
.task {
    let catalog = try await ZeroSettle.shared.fetchProducts()

    // Pre-cache the PaymentIntent while the user browses
    if let first = catalog.products.first {
        await CheckoutSheet.warmUp(productId: first.id, userId: currentUser.id)
    }
}
For the standalone CheckoutSheet init or UIKit’s present method, you can preload manually:
.task {
    preloaded = await CheckoutSheet.preload(
        productId: product.id,
        userId: currentUser.id
    )
}

Show Loading State

Show feedback immediately when a checkout is in progress:
struct PurchaseButton: View {
    @ObservedObject var iap = ZeroSettle.shared
    @State private var selectedProduct: Product?
    let product: ZSProduct

    var body: some View {
        Button {
            selectedProduct = product
        } label: {
            if iap.pendingCheckout {
                ProgressView()
            } else {
                Text("Buy \(product.displayName)")
            }
        }
        .disabled(iap.pendingCheckout)
        .checkoutSheet(
            item: $selectedProduct,
            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 { return }
                showError(error)
            }
        }
    }
}

Handle All Delegate Callbacks

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

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

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

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

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

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

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

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

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

Provide a Restore Button

Apple and Google require a way for users to restore purchases. Add a visible restore option:
Button("Restore Purchases") {
    Task {
        do {
            let entitlements = try await ZeroSettle.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:
struct PurchaseView: View {
    @State private var selectedProduct: Product?
    let product: ZSProduct

    var body: some View {
        Button("Buy") {
            guard Auth.currentUser != nil else {
                // Option 1: Require login first
                showLoginPrompt()
                return
            }
            selectedProduct = product
        }
        .checkoutSheet(
            item: $selectedProduct,
            userId: Auth.currentUser?.id ?? ""
        ) { result in
            switch result {
            case .success(let transaction):
                unlockProduct(transaction.productId)
            case .failure(let error):
                showError(error)
            }
        }
    }
}

Error Handling

Handle Each Error Type

ZeroSettle uses specific error types for different scenarios: iOS:
Error TypeWhen It’s Thrown
ZSErrorSDK configuration and general errors
PaymentSheetErrorPayment sheet presentation and payment errors
StoreKitPurchaseErrorStoreKit purchase errors
CheckoutErrorLegacy checkout view errors (deprecated)
Android:
Error TypeWhen It’s Thrown
ZSError.NotConfiguredSDK not configured before use
ZSError.ProductNotFoundProduct ID not found in catalog
ZSError.CancelledUser dismissed the checkout
ZSError.CheckoutFailedPayment failed (with CheckoutFailure reason)
// checkoutSheet handles errors via its completion handler
.checkoutSheet(item: $selectedProduct, userId: userId) { result in
    switch result {
    case .success(let transaction):
        unlockProduct(transaction.productId)
    case .failure(let error):
        if let zsError = error as? ZSError {
            switch zsError {
            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)
            }
        } else if let sheetError = error as? PaymentSheetError,
                  sheetError == .cancelled {
            // User cancelled — don't show an error
        } else {
            showError(error.localizedDescription)
        }
    }
}

Graceful Degradation

If the SDK fails to initialize or fetch products, fall back gracefully:
struct PaywallView: View {
    @State private var products: [ZSProduct] = []
    @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 ZeroSettle.shared.fetchProducts(
                    userId: Auth.currentUser.id
                ).products
                loadError = nil
            } catch {
                loadError = error
            }
        }
    }
}

Testing

Test the Full Flow

Before releasing, test these scenarios on a physical device:
ScenarioPlatformExpected Behavior
Complete purchase (checkoutSheet)iOSTransaction returned, entitlements update
Complete purchase (Safari fallback)iOSUniversal link callback, entitlements update
Complete purchase (Custom Tab)AndroidTransaction returned, entitlements update
Cancel checkoutSheetiOS.cancelled error, no entitlement change
Cancel Safari fallbackiOSNo entitlement change, no error shown
Cancel Custom Tab checkoutAndroidZSError.Cancelled, no entitlement change
Restore purchasesBothAll previous purchases recovered
App killed during checkoutBothEntitlements sync on next launch
Network offlineBothCached entitlements still work
Different device, same accountBothEntitlements sync via restore
StoreKit purchase (hybrid flow)iOSTransaction synced to ZeroSettle
Play Store purchase (hybrid flow)AndroidTransaction synced to ZeroSettle

Use Sandbox Keys

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

Performance

Fetch Products Once

On iOS, call bootstrap() after configure() to fetch products and cache them in ZeroSettle.shared.products. On Android, call bootstrap() after configure() to fetch and cache products in the ZeroSettle.products StateFlow. There’s no need for a separate fetch call or custom caching on either platform. Use fetchProducts() only for manual refresh (e.g., pull-to-refresh).
// Products already fetched by bootstrap() — just use them
let products = ZeroSettle.shared.products

Prefetch Products & Warm Up

warmUp() and PaymentIntent preloading are iOS-specific APIs. On Android, products are cached in the ZeroSettle.products StateFlow after bootstrap() or fetchProducts() --- no additional warm-up step is needed.
When you call bootstrap(), products are fetched and the first product’s PaymentIntent is warmed up automatically. No separate calls are needed. For manual control (e.g., warming up additional products or refreshing later), fetchProducts() and warmUp() are still available:
struct HomeView: View {
    var body: some View {
        TabView {
            // ...
        }
        .task {
            // Products already fetched by bootstrap() — warm up additional products
            let products = ZeroSettle.shared.products
            for product in products.prefix(3) {
                await CheckoutSheet.warmUp(
                    productId: product.id,
                    userId: Auth.currentUser.id
                )
            }
        }
    }
}
warmUp() caches the PaymentIntent for 5 minutes. If the user reaches the paywall within that window, the sheet opens without any network delay.

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
curl -X GET "https://api.zerosettle.io/v1/iap/entitlements?user_id=<user_id>" \
  -H "X-ZeroSettle-Key: <publishable_key>"

API Key Usage

All ZeroSettle API endpoints — including the REST API — authenticate with your publishable key (zs_pk_live_... or zs_pk_test_...) via the X-ZeroSettle-Key header. This is the same key you use in the SDK.
Key TypeWhere to Use
Publishable (zs_pk_)SDK, REST API, everywhere
Secret (zs_sk_)Reserved for future use