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) { 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. React to outcomes by observing checkout.state:
val state by checkout.state.collectAsStateWithLifecycle()LaunchedEffect(state) { when (val s = state) { is PrimerCheckoutState.Success -> showSuccessScreen(s.checkoutData) is PrimerCheckoutState.Failure -> showErrorScreen(s.primerError) else -> Unit }}
3DS and redirect flows are handled automatically via an internal overlay inside the host.
For full layout control, hold a PrimerCheckoutSession and compose the SDK’s views inside your own layout, wiring them in with the .primerCheckoutSession(_:theme:onCompletion:) modifier. The session injects the per-feature sessions into the environment, so views like PrimerCardForm and PrimerPaymentMethods resolve their state automatically.
struct CustomCheckout: View { @StateObject private var session: PrimerCheckoutSession init(clientToken: String) { _session = StateObject( wrappedValue: PrimerCheckoutSession(clientToken: clientToken) ) } var body: some View { ScrollView { VStack(spacing: 24) { PrimerVaultedPaymentMethods() PrimerPaymentMethods() PrimerCardForm() } .padding() } .primerCheckoutSession(session) { state in if case let .failure(error, _) = state { print("Payment failed: \(error.localizedDescription)") } } }}
Your layout stays on screen. Once the session is .ready, the SDK shows its own follow-up screens in a sheet over your layout. These include processing, the success and error screens, native payment method screens such as Klarna or ACH, the saved payment methods list and CVV recapture. 3DS challenges and redirect flows also open automatically.To handle success and failure only in your own UI, pass PrimerSettings(uiOptions: PrimerUIOptions(isSuccessScreenEnabled: false, isErrorScreenEnabled: false)) to PrimerCheckoutSession(clientToken:settings:). The modifier’s onCompletion closure receives each outcome as a PrimerCheckoutState. See Events for the order.For the prebuilt, fully managed screens instead, use PrimerCheckout.
When you compose your own layout, your views are responsible for the embedded UI. The SDK still handles payment logic, validation, and state management. It also presents its own sheet over your layout, as described above. You drive the embedded UI through each view’s @ViewBuilder slots and call the session methods (for example session.submit() on the PrimerCardFormSession handed to a slot).
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
val state by checkout.state.collectAsStateWithLifecycle()LaunchedEffect(state) { when (val s = state) { is PrimerCheckoutState.Success -> { navController.navigate("confirmation/${s.checkoutData.payment.id}") } is PrimerCheckoutState.Failure -> { showError(s.primerError.description) } else -> Unit }}PrimerCheckoutHost(checkout = checkout) { // Your custom layout}
Android delivers payment outcomes as terminal cases of the single PrimerCheckoutState flow (Success, Failure), observed via checkout.state. All state updates are delivered reactively via StateFlow.
On iOS, observe state changes reactively by reading the @Publishedstate on the session a slot hands you. The SDK view already observes that session, so the slot re-renders when state changes:
If you pass the session into your own view, hold it as @ObservedObject so that view re-renders too.Payment outcomes are reported through the onCompletion closure on the .primerCheckoutSession(_:theme:onCompletion:) modifier as a PrimerCheckoutState:
.primerCheckoutSession(session) { state in switch state { case .success(let result): print("Payment succeeded: \(result.paymentId)") case let .failure(error, _): print("Payment failed: \(error.localizedDescription)") case .dismissed: print("Checkout dismissed") default: break }}
onCompletion receives .failure once for each failed payment attempt or failed refresh(), and the checkout stays usable so the user can retry. It then receives .success or .dismissed exactly once, and nothing after that. If initialization fails, that .failure is the final outcome and nothing follows it.iOS reports outcomes to this single onCompletion closure instead of multiple event listeners. The session lifecycle (.initializing, .ready) is the @Publishedphase on PrimerCheckoutSession, not part of onCompletion. Form and payment method state is delivered reactively via the @Publishedstate on each session (PrimerCardFormSession, PrimerSelectionSession).
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.
Override the method slot on PrimerPaymentMethods to render your own row. The slot receives a CheckoutPaymentMethod and an onSelect closure — call onSelect to start that method’s flow. The view resolves its PrimerSelectionSession from the environment and renders one row per available method:
There is no SDK API to filter or sort payment methods server-side. To build a fully custom list, read the available methods from session.state.paymentMethods and filter them client-side before rendering, calling session.select(_:) on the PrimerSelectionSession when a row is tapped:
struct CustomPaymentMethodList: View { @ObservedObject var session: PrimerSelectionSession var body: some View { let methods = session.state.paymentMethods.filter { ["PAYMENT_CARD", "PAYPAL"].contains($0.type) } VStack { ForEach(methods) { method in Button { session.select(method) } label: { HStack { if let icon = method.icon { Image(uiImage: icon).resizable().scaledToFit().frame(width: 40, height: 28) } Text(method.name) Spacer() } .padding() } } } }}
Pass the view the selection property of your PrimerCheckoutSession. Inside a view wrapped by .primerCheckoutSession(_:theme:onCompletion:), read it with @Environment(\.primerSelectionSession) instead. selection is non-nil once the session is .ready. state is @Published, so the list re-renders when the methods change. When you call refresh() on the session, it builds a new selection session. Read it again from selection or the environment instead of keeping the old instance.
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, PrimerCardForm and PrimerPaymentMethods are separate views. PrimerPaymentMethods lists every available payment method, including PAYMENT_CARD. Tapping the card row opens the SDK’s card form in a sheet. If you also render PrimerCardForm in your own layout under .primerCheckoutSession(_:theme:onCompletion:), the user gets two card forms. Hide the card row in the method slot:
ScrollView { VStack(spacing: 24) { PrimerCardForm() PrimerPaymentMethods(method: { method, onSelect in if method.type != "PAYMENT_CARD" { PaymentMethodsDefaults.method(method, onSelect: onSelect) } }) }}.primerCheckoutSession(session) { state in handle(state)}
PaymentMethodsDefaults.method(_:onSelect:) keeps the default row for every other method, so only the card row is removed. If you build your own list instead, filter session.state.paymentMethods as shown in the client-side filtering example above.
Listen for relevant events - Handle checkout state through event listeners (Web) or by observing checkout.state (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 match on PrimerCheckoutState.Failure from checkout.state (Android)