Skip to main content
checkoutSheet is an embedded checkout experience that presents a native-feeling bottom sheet inside your app. Users can pay with Apple Pay or enter a card — without ever leaving your app. It automatically reads your dashboard configuration to choose the correct checkout mode (webview, Safari VC, or external Safari). This is the recommended way to accept payments with ZeroSettle.

SwiftUI

Basic Usage

Use the .checkoutSheet() view modifier to present the checkout sheet:
struct PaywallView: View {
    @State private var selectedProduct: ZSProduct?
    let product: ZSProduct

    var body: some View {
        Button("Subscribe — \(product.webPrice.formatted)") {
            selectedProduct = product
        }
        .checkoutSheet(item: $selectedProduct, userId: currentUser.id) { result in
            switch result {
            case .success(let transaction):
                unlockContent(transaction.productId)
            case .failure(let error):
                showError(error)
            }
        }
    }
}

Custom Header

Add a custom header view above the checkout sheet to show product details, branding, or promotional messaging:
.checkoutSheet(item: $selectedProduct, userId: currentUser.id, header: {
    VStack(spacing: 8) {
        Image("premium-icon")
            .resizable()
            .frame(width: 60, height: 60)
        Text(selectedProduct?.displayName ?? "")
            .font(.title2.bold())
        Text(selectedProduct?.productDescription ?? "")
            .font(.subheadline)
            .foregroundStyle(.secondary)
    }
    .padding()
}) { result in
    // Handle result
}

Item-Based Presentation

The checkoutSheet modifier uses an item-based binding by default. Pass an optional ZSProduct? binding — the sheet presents when the binding is non-nil and automatically sets it back to nil on dismiss:
struct PaywallView: View {
    @ObservedObject var iap = ZeroSettle.shared
    @State private var selectedProduct: ZSProduct?

    var body: some View {
        ForEach(iap.products) { product in
            Button(product.displayName) {
                selectedProduct = product
            }
        }
        .checkoutSheet(item: $selectedProduct, userId: currentUser.id) { result in
            handleResult(result)
        }
    }
}

Non-Dismissible Sheet

Pass dismissible: false to prevent the user from swiping to dismiss the sheet. This hides the close button and disables interactive dismiss:
.checkoutSheet(item: $selectedProduct, userId: currentUser.id, dismissible: false) { result in
    handleResult(result)
}

Direct View Usage

You can also use CheckoutSheet as a standalone view, for example inside a .sheet():
.sheet(item: $selectedProduct) { product in
    CheckoutSheet(
        product: product,
        userId: currentUser.id
    ) { result in
        selectedProduct = nil
        handleResult(result)
    }
}
Or with a custom header:
.sheet(item: $selectedProduct) { product in
    CheckoutSheet(
        product: product,
        userId: currentUser.id,
        header: {
            Text("Premium Access")
                .font(.title.bold())
                .padding()
        }
    ) { result in
        selectedProduct = nil
        handleResult(result)
    }
}
The SwiftUI-specific APIs above (checkoutSheet modifier, custom headers, dismissible:) are iOS-only. On Android, use ZeroSettle.purchase() which handles all presentation automatically. See the Android section below.

UIKit

Present the payment sheet from any view controller:
CheckoutSheet.present(
    from: self,
    product: product,
    userId: currentUser.id
) { result in
    switch result {
    case .success(let transaction):
        self.unlockContent(transaction.productId)
    case .failure(let error):
        self.showError(error)
    }
}
You can also pass dismissible: false, or provide a preloaded checkoutURL and transactionId for instant presentation:
CheckoutSheet.present(
    from: self,
    product: product,
    userId: currentUser.id,
    dismissible: false,
    checkoutURL: preloadedURL,
    transactionId: preloadedTransactionId
) { result in
    handleResult(result)
}

Kotlin / Android

On Android, ZeroSettle.purchase() is a single suspend function that handles creating the PaymentIntent, launching the checkout UI, and returning the completed transaction. There is no separate modifier or preloading step.
@Composable
fun PaywallScreen(product: ZSProduct) {
    val context = LocalContext.current
    val activity = context as Activity
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            try {
                val transaction = ZeroSettle.purchase(
                    activity = activity,
                    productId = product.id,
                    userId = currentUser.id,
                )
                unlockContent(transaction.productId)
            } catch (e: ZSError.Cancelled) {
                // User dismissed — no action needed
            } catch (e: ZSError.CheckoutFailed) {
                showError(e.message)
            } catch (e: Exception) {
                showError(e.message ?: "Unknown error")
            }
        }
    }) {
        Text("Subscribe — ${product.webPrice?.formatted}")
    }
}

Presentation API

The checkoutSheet modifier uses an item: binding (Binding<ZSProduct?>) that mirrors SwiftUI’s .sheet(item:) pattern. Set the binding to a product to present the sheet, and it automatically resets to nil on dismiss. This works for both single-product paywalls (set selectedProduct = product on a “Buy” button) and multi-product stores (set selectedProduct from a list selection). The modifier shares the same caching and preloading behavior described below.
On Android, there is a single ZeroSettle.purchase() API that handles all presentation. The checkoutSheet modifier is iOS-only.

Performance & Caching

Automatic Preloading

The .checkoutSheet() view modifier automatically preloads both the PaymentIntent and the WKWebView before presenting the sheet. When the user taps “Buy”, the checkout is fully rendered the moment it slides up — no manual preloading is needed.
// The modifier handles preloading automatically — just use it as normal
.checkoutSheet(item: $selectedProduct, userId: currentUser.id) { result in
    handleResult(result)
}

Built-In Caching

The SDK automatically caches PaymentIntent results (the checkout URL and transaction ID) for 5 minutes. This means:
  • Re-opens after dismiss are near-instant — if the user dismisses and re-opens the sheet for the same product, the API call is skipped entirely. Only the WebView needs to reload.
  • The cache is shared across all payment sheet instances in your app.
  • The cache invalidates automatically after a successful payment, so the next open gets a fresh PaymentIntent.
No code changes are needed to benefit from caching — it works automatically with the checkoutSheet modifier.

Eager Warm-Up

When you provide userId in configure(), the first product’s PaymentIntent is automatically warmed up. For most apps, this is sufficient and no manual warm-up is needed. For advanced scenarios — such as warming up multiple products or a specific product the user is likely to buy — call CheckoutSheet.warmUp() manually:
struct StoreView: View {
    @ObservedObject var iap = ZeroSettle.shared
    @State private var selectedProduct: ZSProduct?

    var body: some View {
        ProductList(products: iap.products, selection: $selectedProduct)
            .task {
                // Products already fetched by configure() — warm up additional ones
                for product in iap.products.prefix(3) {
                    await CheckoutSheet.warmUp(
                        productId: product.id,
                        userId: currentUser.id
                    )
                }
            }
            .checkoutSheet(item: $selectedProduct, userId: currentUser.id) { result in
                handleResult(result)
            }
    }
}
warmUp() caches the PaymentIntent for 5 minutes. If the user reaches the paywall within that window, the sheet opens without any network delay.
Preloading and warm-up are not available on Android. ZeroSettle.purchase() creates the PaymentIntent at call time. The checkout UI is presented as soon as the intent is ready.

UX Impact

Understanding what’s cached and what must reload helps you set the right expectations:
ScenarioAPI CallWebView LoadPerceived Speed
First open (no warm-up)Network callFull load~1-2s depending on network
First open (with warmUp())Cache hitFull load~0.5-1s (WebView only)
Re-open after dismiss (same product)Cache hitFull load~0.5-1s (WebView only)
Re-open after successful paymentNetwork callFull load~1-2s (cache was invalidated)
The API call is typically 200-500ms. Warm-up eliminates this entirely for the first open.

Manual Preloading

For the direct CheckoutSheet init or UIKit’s present method, you can preload the PaymentIntent yourself using CheckoutSheet.preload():
// Preload while the user browses
let preloaded = await CheckoutSheet.preload(
    productId: product.id,
    userId: currentUser.id
)

// Later, pass the preloaded data to the direct init
.sheet(item: $selectedProduct) { product in
    CheckoutSheet(
        product: product,
        userId: currentUser.id,
        checkoutURL: preloaded?.checkoutURL,
        transactionId: preloaded?.transactionId
    ) { result in
        handleResult(result)
    }
}
preload() also populates the cache, so subsequent calls for the same product + user are instant. warmUp() is just a convenience wrapper around preload().
Manual preloading is only needed when using CheckoutSheet as a standalone view or via UIKit’s present method. The .checkoutSheet() modifier handles this automatically.

Declarative Preloading (Swift)

For the .checkoutSheet() modifier, you can pass a preload: parameter to automatically preload payment intents:
Swift
// Preload all products
.checkoutSheet(item: $selectedProduct, userId: currentUser.id, preload: .all) { result in
    handleResult(result)
}

// Preload specific products
.checkoutSheet(
    item: $selectedProduct,
    userId: currentUser.id,
    preload: .specified(iap.products.prefix(3).map { $0 })
) { result in
    handleResult(result)
}
StrategyBehavior
.allPreloads PaymentIntents for all fetched products
.specified([ZSProduct])Preloads only the specified products
nil (default)Preloads only the bound product
This is an iOS-only SwiftUI feature. On Android, ZeroSettle.purchase() creates the PaymentIntent at call time.

Android

On Android, the checkout sheet is presented as a ZSPaymentSheetActivity — a full-screen Activity with an embedded WebView. The SDK handles creating and launching this Activity automatically when you call purchase().

Compose

@Composable
fun PaywallScreen(product: ZSProduct) {
    val context = LocalContext.current
    val activity = context as Activity
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            try {
                val transaction = ZeroSettle.purchase(
                    activity = activity,
                    productId = product.id,
                    userId = currentUser.id,
                )
                unlockContent(transaction.productId)
            } catch (e: ZSError.Cancelled) {
                // User dismissed — no action needed
            } catch (e: ZSError.CheckoutFailed) {
                when (e.reason) {
                    is CheckoutFailure.NetworkUnavailable -> showRetry("No internet connection")
                    is CheckoutFailure.StripeError -> showError(e.reason.message)
                    else -> showError(e.message)
                }
            } catch (e: ZSError.NotConfigured) {
                // SDK not configured — call ZeroSettle.configure() first
            } catch (e: Exception) {
                showError(e.message ?: "Unknown error")
            }
        }
    }) {
        Text("Subscribe — ${product.webPrice?.formatted}")
    }
}

Activity / Fragment

class PaywallActivity : AppCompatActivity() {

    private fun purchaseProduct(product: ZSProduct) {
        lifecycleScope.launch {
            try {
                val transaction = ZeroSettle.purchase(
                    activity = this@PaywallActivity,
                    productId = product.id,
                    userId = currentUser.id,
                )
                unlockContent(transaction.productId)
            } catch (e: ZSError.Cancelled) {
                // User dismissed — no action needed
            } catch (e: ZSError.CheckoutFailed) {
                when (e.reason) {
                    is CheckoutFailure.NetworkUnavailable -> showRetry("No internet connection")
                    is CheckoutFailure.StripeError -> showError(e.reason.message)
                    else -> showError(e.message)
                }
            } catch (e: ZSError.NotConfigured) {
                // SDK not configured — call ZeroSettle.configure() first
            } catch (e: Exception) {
                showError(e.message ?: "Unknown error")
            }
        }
    }
}

Checkout Modes

The checkout mode is controlled by your remote config (set from the ZeroSettle dashboard). The SDK reads this automatically at runtime:
ModeBehavior
WEBVIEW (default)Launches ZSPaymentSheetActivity — a full-screen Activity with an embedded WebView. This provides the most native-feeling experience.
CUSTOM_TABOpens the checkout page in a Chrome Custom Tab. Shares cookies with Chrome for autofill support.
EXTERNAL_BROWSEROpens the checkout page in the user’s default browser. Use this as a fallback if Custom Tabs are unavailable.
You do not need to specify the checkout mode in code — purchase() reads it from the remote config and routes automatically.

Android Error Handling

ZeroSettle.purchase() is a suspend function that throws on failure. Errors are thrown as subtypes of ZSError:
try {
    val transaction = ZeroSettle.purchase(activity, productId, userId)
    unlockContent(transaction.productId)
} catch (e: ZSError.Cancelled) {
    // User dismissed — no action needed
} catch (e: ZSError.CheckoutFailed) {
    when (e.reason) {
        is CheckoutFailure.NetworkUnavailable -> showRetry("No internet connection")
        is CheckoutFailure.StripeError -> showError(e.reason.message)
        is CheckoutFailure.ServerError -> showError("Server error: ${e.reason.statusCode}")
        is CheckoutFailure.ProductNotFound -> showError("Product not found")
        is CheckoutFailure.MerchantNotOnboarded -> showError("Payment setup incomplete")
        is CheckoutFailure.Other -> showError(e.reason.message)
    }
} catch (e: ZSError.NotConfigured) {
    // SDK not configured — call ZeroSettle.configure() first
} catch (e: ZSError.UserIdRequired) {
    // A userId is required for subscriptions and non-consumable products
} catch (e: Exception) {
    showError(e.message ?: "Unknown error")
}
The iOS-specific APIs (checkoutSheet modifier, warmUp(), preload()) are not available on Android. On Android, use ZeroSettle.purchase() directly — it creates the PaymentIntent and presents the checkout UI in a single call.

How It Works

Under the hood, CheckoutSheet:
  1. Checks the PaymentIntent cache (5-min TTL) — on a cache hit, skips the network call
  2. If no cache hit, creates a Stripe PaymentIntent via the ZeroSettle API and caches the result
  3. Loads the checkout page in a WKWebView with Apple Pay and card entry
  4. Communicates payment status back to your app via a JavaScript bridge
  5. Verifies the transaction server-side and invalidates the cache for this product
  6. Returns a CheckoutTransaction to your completion handler
The user sees a native-looking sheet with Apple Pay as the primary option and a card form as fallback.

Error Handling

The completion handler receives a Result<CheckoutTransaction, Error>. Payment-specific errors are of type PaymentSheetError (iOS) or ZSError (Android):
) { result in
    switch result {
    case .success(let transaction):
        unlockContent(transaction.productId)

    case .failure(let error):
        if let sheetError = error as? PaymentSheetError {
            switch sheetError {
            case .cancelled:
                // User dismissed the sheet — no action needed
                break
            case .notConfigured:
                // SDK not configured — call configure() first
                break
            case .paymentFailed(let detail):
                // Payment processing failed
                switch detail.kind {
                case .cardDeclined:
                    showError("Please check your card details.")
                default:
                    showError(detail.message)
                }
            case .verificationFailed(let detail):
                showError("Verification failed: \(detail)")
            case .preloadFailed:
                showError("Could not load checkout.")
            case .userIdRequired:
                showError("Please sign in to purchase.")
            }
        }
    }
}

Error Types

iOS (PaymentSheetError)

ErrorDescription
.cancelledUser dismissed the payment sheet
.notConfiguredSDK hasn’t been configured yet
.paymentFailed(PaymentFailureDetail)Payment failed — inspect .kind and .message for details
.verificationFailed(String)Transaction completed but server verification failed
.preloadFailed(APIErrorDetail)PaymentIntent creation failed before sheet could open
.userIdRequiredA userId is required for subscriptions and non-consumable products
PaymentFailureDetail.Kind
KindDescription
.cardDeclinedPayment method was declined
.networkErrorNetwork connectivity issue
.serverErrorServer returned an error
.checkoutErrorCheckout flow error
.unknownUnclassified failure

Android (ZSError)

ErrorDescription
ZSError.CancelledUser dismissed the payment sheet
ZSError.NotConfiguredSDK hasn’t been configured yet — call configure() first
ZSError.CheckoutFailed(reason)Checkout failed — inspect reason for details (see CheckoutFailure below)
ZSError.UserIdRequired(productId)A userId is required for subscriptions and non-consumable products
ZSError.TransactionVerificationFailed(detail)Transaction completed but server-side verification failed

Android CheckoutFailure reasons

ReasonDescription
CheckoutFailure.NetworkUnavailableNo network connection
CheckoutFailure.StripeError(code, message)Stripe returned an error (e.g., card declined)
CheckoutFailure.ServerError(statusCode, message)Server returned a non-2xx response
CheckoutFailure.ProductNotFoundThe product was not found on the server
CheckoutFailure.MerchantNotOnboardedThe merchant has not completed Stripe onboarding
CheckoutFailure.Other(message)An unclassified error occurred
.cancelled / ZSError.Cancelled is not a real error — it just means the user changed their mind. Don’t show an error message for cancellations.

Promotions

If a product has an active promotion, the payment sheet automatically shows the promotional price. The ZSProduct.promotion property contains the details:
if let promo = product.promotion {
    print("\(promo.displayName): \(promo.promotionalPrice.formatted)")
    // e.g. "Launch Sale: $4.99"
}
Promotion types include:
  • Percent off — e.g., 50% off
  • Fixed amount — e.g., $5 off
  • Free trial — e.g., 7 days free

Free Trials

Pass freeTrialDays to offer a trial period before the first charge:
.checkoutSheet(item: $selectedProduct, userId: currentUser.id, freeTrialDays: 7) { result in
    handleResult(result)
}
The freeTrialDays parameter defaults to 0 (no trial). When set, the payment sheet displays the trial period and the user is not charged until the trial expires.

Next Steps