For complete control, you can bypass <primer-main> entirely and provide your own implementation.Choose one error display approach:Option A: Built-in error container
<primer-checkout client-token="your-token"> <div slot="main" id="custom-checkout"> <div id="payment-methods"> <primer-payment-method type="PAYMENT_CARD"></primer-payment-method> <!-- Your own error element — requires event handling in JavaScript --> <div id="my-custom-error" class="custom-error-message"></div> </div> </div></primer-checkout>
Implementation responsibilityWhen using this approach:
You must handle state management yourself through events
You have complete freedom over the layout and user flow
You’re responsible for showing/hiding appropriate content based on checkout state
You need to handle payment failure display, either with the <primer-error-message-container> component or by implementing custom error handling with events
Use PrimerCheckoutHost with a content lambda for full layout control. You compose child components freely inside the host.
val state by checkout.state.collectAsStateWithLifecycle()PrimerCheckoutHost(checkout, onEvent = ::handleEvent) { if (state is PrimerCheckoutState.Ready) { Column { val vaultedController = rememberVaultedPaymentMethodsController(checkout) PrimerVaultedPaymentMethods(vaultedController) val paymentMethodsController = rememberPaymentMethodsController(checkout) PrimerPaymentMethods(paymentMethodsController) val cardFormController = rememberCardFormController(checkout) PrimerCardForm(controller = cardFormController) } }}
PrimerCheckoutHost does not have built-in error or success slots. Handle all outcomes via the onEvent callback:
fun handleEvent(event: PrimerCheckoutEvent) { when (event) { is PrimerCheckoutEvent.Success -> showSuccessScreen(event.checkoutData) is PrimerCheckoutEvent.Failure -> showErrorScreen(event.error) }}
3DS and redirect flows are handled automatically via an internal overlay inside the host.
For full layout control, replace entire screens with custom SwiftUI views via the scope closure. The scope provides access to state, actions, and SDK-managed fields.
PrimerCheckout( clientToken: clientToken, scope: { checkoutScope in // Custom splash and error screens checkoutScope.splashScreen = { AnyView(MySplashScreen()) } checkoutScope.errorScreen = { msg in AnyView(MyErrorScreen(message: msg)) } // Full custom card form screen if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) { cardScope.screen = { scope in AnyView(MyCardForm(scope: scope)) } } }, onCompletion: { state in if case .failure(let error) = state { print("Payment failed: \(error.errorDescription ?? "")") } })
When replacing a full screen, your view is responsible for all UI elements. The SDK still handles payment logic, validation, and state management, but you must call the appropriate scope methods (e.g., submit(), onBack()) from your custom view.
When implementing a custom layout, you need to listen for events to manage checkout states.For comprehensive information on all available events, event payloads, and best practices, see the Events Guide.
Web
Android
iOS
document .querySelector('primer-checkout') .addEventListener('primer:state-change', (event) => { const state = event.detail; if (state.isProcessing) { // Show loading indicator } else if (state.isSuccessful) { // Show success message } else if (state.primerJsError || state.paymentFailure) { // Show error message const errorMessage = state.primerJsError?.message || state.paymentFailure?.message; // Display error to user } });
Key events to listen for
primer:state-change - Fired when checkout state changes
primer:methods-update - Fired when available payment methods are loaded
primer:ready - Fired when the SDK is ready
PrimerCheckoutHost( checkout = checkout, onEvent = { event -> when (event) { is PrimerCheckoutEvent.Success -> { navController.navigate("confirmation/${event.checkoutData.payment.id}") } is PrimerCheckoutEvent.Failure -> { showError(event.error.description) } } },) { // Your custom layout}
Android uses a single onEvent callback instead of multiple event listeners. State updates are delivered reactively via StateFlow on individual controllers.
On iOS, observe state changes via AsyncStream on scope objects:
Task { for await state in cardFormScope.state { if state.isValid { // Enable submit button } if state.isLoading { // Show loading indicator } }}
Payment outcomes are reported through the onCompletion callback:
PrimerCheckout( clientToken: clientToken, onCompletion: { state in switch state { case .success(let data): print("Payment succeeded: \(data)") case .failure(let error): print("Payment failed: \(error.errorId)") } })
The type attribute specifies which payment method to display. If a payment method isn’t available in your Dashboard configuration, it simply won’t render.
Payment method filtering with include, exclude and type
The primer-payment-method-container component provides a declarative way to organize payment methods:
<!-- Sectioned layout example --><div slot="payments"> <!-- Quick pay options --> <primer-payment-method-container include="APPLE_PAY,GOOGLE_PAY" ></primer-payment-method-container> <!-- Alternative methods --> <primer-payment-method-container exclude="PAYMENT_CARD,APPLE_PAY,GOOGLE_PAY" ></primer-payment-method-container> <!-- Card form --> <primer-payment-method type="PAYMENT_CARD"></primer-payment-method></div>
This approach automatically filters available payment methods without requiring event listeners or manual state management. See the Payment Method Container SDK Reference documentation for complete usage guide.
There is no SDK API to filter or sort payment methods server-side. Filter the methods list client-side before rendering:
val controller = rememberPaymentMethodsController(checkout)val methods by controller.methods.collectAsStateWithLifecycle()val filtered = methods.filter { it.paymentMethodType in listOf("PAYMENT_CARD", "PAYPAL")}
The StateFlow updates automatically when checkout.refresh() is called.
Use PrimerPaymentMethodSelectionScope.state to access the list of available payment methods as an AsyncStream:
struct CustomPaymentMethodList: View { let scope: PrimerPaymentMethodSelectionScope @State private var methods: [CheckoutPaymentMethod] = [] var body: some View { VStack { ForEach(methods, id: \.id) { method in Button { scope.onPaymentMethodSelected(paymentMethod: method) } label: { HStack { if let icon = method.icon { Image(uiImage: icon).resizable().frame(width: 40, height: 28) } Text(method.name) Spacer() } .padding() } } } .task { for await state in scope.state { methods = state.paymentMethods } } }}
Filter payment methods client-side before rendering:
let cardAndPaypal = methods.filter { ["PAYMENT_CARD", "PAYPAL"].contains($0.type) }
When customizing your checkout layout, be careful not to render duplicate card forms. This commonly happens when:
You create a custom card form using <primer-card-form>
You also include <primer-payment-method type="PAYMENT_CARD"> in your layout
<!-- INCORRECT: Will result in duplicate card forms --><div slot="payments"> <!-- Custom card form --> <primer-card-form> <!-- Custom card form content --> </primer-card-form> <!-- This will render ANOTHER card form --> <primer-payment-method type="PAYMENT_CARD"></primer-payment-method></div>
If you’re using a custom card form implementation, you should not include the PAYMENT_CARD payment method in your layout.
Filtering to avoid duplicate card forms
Important: If you’re using a custom card form, you should filter out the PAYMENT_CARD type to avoid duplicate card forms:
On Android, this issue does not apply. PrimerCardForm and PrimerPaymentMethods are separate composables that don’t conflict. When using PrimerCheckoutHost, you compose them independently:
If you want to exclude card payments from PrimerPaymentMethods (because you render PrimerCardForm separately), filter the methods list client-side:
val controller = rememberPaymentMethodsController(checkout)val methods by controller.methods.collectAsStateWithLifecycle()val nonCardMethods = methods.filter { it.paymentMethodType != "PAYMENT_CARD" }
On iOS, this issue does not apply. The card form scope and the payment method selection scope are separate. If you replace the card form screen via cardScope.screen, it does not duplicate the card entry in the payment method selection list.If you want to exclude card payments from the selection list when rendering a separate card form, filter the methods list:
let nonCardMethods = methods.filter { $0.type != "PAYMENT_CARD" }
Listen for relevant events - Handle checkout state through event listeners (Web) or onEvent callbacks (Android)
Design responsively - Ensure your layout works on all device sizes
Test thoroughly - Validate behavior across different payment methods and scenarios
Prevent component flash - Use CSS or JavaScript techniques to hide content until components are defined (Web), or observe PrimerCheckoutState.Ready before rendering (Android)
Handle payment failures - Either use the <primer-error-message-container> component (Web) or handle PrimerCheckoutEvent.Failure in onEvent (Android)