> ## Documentation Index
> Fetch the complete documentation index at: https://primer.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Advanced layout with events

> Build fully custom layouts with events, dynamic rendering and payment method filtering.

This guide covers advanced layout customization techniques for when you need complete control over your checkout experience.

## Fully custom implementation

> [tab] Web
>
> 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**
>
> ```html theme={"dark"}
> <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>
>
>       <!-- Pre-built component handles payment failure display automatically -->
>       <primer-error-message-container></primer-error-message-container>
>     </div>
>   </div>
> </primer-checkout>
> ```
>
> **Option B: Custom error element**
>
> ```html theme={"dark"}
> <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>
> ```
>
> > [!WARNING]
> >
> > **Implementation responsibility**
> >
> > When 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

> [tab] Android
>
> Use `PrimerCheckoutHost` with a `content` lambda for full layout control. You compose child components freely inside the host.
>
> ```kotlin theme={"dark"}
> 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)
>         }
>     }
> }
> ```
>
> > [!NOTE]
> >
> > `PrimerCheckoutHost` does not have built-in error or success slots. React to outcomes by observing `checkout.state`:
> >
> > ```kotlin theme={"dark"}
> > 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.

> [tab] iOS
>
> 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.
>
> ```swift theme={"dark"}
> 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)")
>       }
>     }
>   }
> }
> ```
>
> > [!NOTE]
> >
> > 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`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state). See [Events](#events) for the order.
> >
> > For the prebuilt, fully managed screens instead, use [`PrimerCheckout`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout).
>
> > [!WARNING]
> >
> > 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`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/card-form/card-form-session) handed to a slot).

## Events

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](https://primer.io/docs/checkout/primer-checkout/configuration/events.md).

> [tab] Web
>
> ```javascript theme={"dark"}
> 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
>     }
>   });
> ```
>
> > [accordion] 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

> [tab] Android
>
> ```kotlin theme={"dark"}
> 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`.

> [tab] iOS
>
> On iOS, observe state changes reactively by reading the `@Published` `state` on the session a slot hands you. The SDK view already observes that session, so the slot re-renders when `state` changes:
>
> ```swift theme={"dark"}
> PrimerCardForm(submitButton: { session in
>   Button("Pay") { session.submit() }
>     .disabled(!session.state.isValid || session.state.isLoading)
> })
> ```
>
> 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`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state):
>
> ```swift theme={"dark"}
> .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 `@Published` `phase` on `PrimerCheckoutSession`, not part of `onCompletion`. Form and payment method state is delivered reactively via the `@Published` `state` on each session (`PrimerCardFormSession`, `PrimerSelectionSession`).

## Configuring payment methods

> [tab] Web
>
> When customizing the payment method layout, you can include specific payment methods:
>
> ```html theme={"dark"}
> <div slot="payments">
>   <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
>   <primer-payment-method type="PAYPAL"></primer-payment-method>
> </div>
> ```
>
> 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:
>
> ```html theme={"dark"}
> <!-- 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](https://primer.io/docs/sdk/primer-checkout-web/components/primer-payment-method-container.md) for complete usage guide.
>
> ### Dynamic rendering with events
>
> You can also dynamically render payment methods by listening to the `primer:methods-update` event:
>
> > [accordion] Example: Dynamic payment method rendering
> >
> > ```javascript theme={"dark"}
> > checkout.addEventListener('primer:methods-update', (event) => {
> >   const availableMethods = event.detail.toArray();
> >   const container = document.getElementById('payment-methods');
> >
> >   availableMethods.forEach((method) => {
> >     const element = document.createElement('primer-payment-method');
> >     element.setAttribute('type', method.type);
> >     container.appendChild(element);
> >   });
> > });
> > ```
>
> This approach ensures you only display payment methods that are actually available.

> [tab] Android
>
> Use `rememberPaymentMethodsController()` to access the available payment methods as a reactive `StateFlow`:
>
> ```kotlin theme={"dark"}
> val controller = rememberPaymentMethodsController(checkout)
> val methods by controller.methods.collectAsStateWithLifecycle()
>
> LazyColumn {
>     items(methods) { method ->
>         PaymentMethodCard(
>             name = method.paymentMethodName ?: method.paymentMethodType,
>             onClick = { controller.select(method) },
>         )
>     }
> }
> ```
>
> ### Client-side filtering
>
> There is no SDK API to filter or sort payment methods server-side. Filter the `methods` list client-side before rendering:
>
> ```kotlin theme={"dark"}
> 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.

> [tab] iOS
>
> 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`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/payment-methods/payment-methods-session) from the environment and renders one row per available method:
>
> ```swift theme={"dark"}
> PrimerPaymentMethods(
>   method: { paymentMethod, onSelect in
>     Button(action: onSelect) {
>       HStack {
>         if let icon = paymentMethod.icon {
>           Image(uiImage: icon)
>             .resizable()
>             .scaledToFit()
>             .frame(width: 40, height: 28)
>         }
>         Text(paymentMethod.buttonText ?? paymentMethod.name)
>         Spacer()
>       }
>       .padding()
>     }
>     .buttonStyle(.plain)
>   }
> )
> ```
>
> ### Client-side filtering
>
> 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`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/payment-methods/payment-methods-session) when a row is tapped:
>
> ```swift theme={"dark"}
> 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`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session). 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.

## Avoiding duplicate card forms

> [tab] Web
>
> > [!WARNING]
> >
> > When customizing your checkout layout, be careful not to render duplicate card forms. This commonly happens when:
> >
> > 1. You create a custom card form using `<primer-card-form>`
> > 2. You also include `<primer-payment-method type="PAYMENT_CARD">` in your layout
>
> ```html theme={"dark"}
> <!-- 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.
>
> > [accordion] 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:
> >
> > ```javascript theme={"dark"}
> > checkout.addEventListener('primer:methods-update', (event) => {
> >   let availableMethods = event.detail.toArray();
> >   const container = document.getElementById('payment-methods');
> >
> >   if (document.querySelector('primer-card-form')) {
> >     availableMethods = availableMethods.filter(
> >       (method) => method.type !== 'PAYMENT_CARD',
> >     );
> >   }
> >
> >   availableMethods.forEach((method) => {
> >     const element = document.createElement('primer-payment-method');
> >     element.setAttribute('type', method.type);
> >     container.appendChild(element);
> >   });
> > });
> > ```

> [tab] Android
>
> On Android, this issue does not apply. `PrimerCardForm` and `PrimerPaymentMethods` are separate composables that don't conflict. When using `PrimerCheckoutHost`, you compose them independently:
>
> ```kotlin theme={"dark"}
> PrimerCheckoutHost(checkout) {
>     Column {
>         PrimerCardForm(controller = rememberCardFormController(checkout))
>         PrimerPaymentMethods(rememberPaymentMethodsController(checkout))
>     }
> }
> ```
>
> If you want to exclude card payments from `PrimerPaymentMethods` (because you render `PrimerCardForm` separately), filter the methods list client-side:
>
> ```kotlin theme={"dark"}
> val controller = rememberPaymentMethodsController(checkout)
> val methods by controller.methods.collectAsStateWithLifecycle()
> val nonCardMethods = methods.filter { it.paymentMethodType != "PAYMENT_CARD" }
> ```

> [tab] iOS
>
> 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:
>
> ```swift theme={"dark"}
> 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.

## Best practices

> [!TIP]
>
> 1. **Listen for relevant events** - Handle checkout state through event listeners (Web) or by observing `checkout.state` (Android)
> 2. **Design responsively** - Ensure your layout works on all device sizes
> 3. **Test thoroughly** - Validate behavior across different payment methods and scenarios
> 4. **Prevent component flash** - Use CSS or JavaScript techniques to hide content until components are defined (Web), or observe `PrimerCheckoutState.Ready` before rendering (Android)
> 5. **Handle payment failures** - Either use the `<primer-error-message-container>` component (Web) or match on `PrimerCheckoutState.Failure` from `checkout.state` (Android)

## See also

> [card] [Error handling](https://primer.io/docs/checkout/primer-checkout/build-your-ui/error-handling.md)
>
> Handle payment failures and display error messages

> [card] [Events guide](https://primer.io/docs/checkout/primer-checkout/configuration/events.md)
>
> Complete reference for all checkout events


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.