Fully custom implementation
- Web
- Android
- iOS
For complete control, you can bypass Option B: Custom error element
<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>
<!-- Pre-built component handles payment failure display automatically -->
<primer-error-message-container></primer-error-message-container>
</div>
</div>
</primer-checkout>
<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 3DS and redirect flows are handled automatically via an internal overlay inside the host.
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
}
}
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).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.- 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
Key events to listen for
primer:state-change- Fired when checkout state changesprimer:methods-update- Fired when available payment methods are loadedprimer: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
}
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 If you pass the session into your own view, hold it as
@Published state on the session a slot hands you. The SDK view already observes that session, so the slot re-renders when state changes:PrimerCardForm(submitButton: { session in
Button("Pay") { session.submit() }
.disabled(!session.state.isValid || session.state.isLoading)
})
@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 @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
- Web
- Android
- iOS
When customizing the payment method layout, you can include specific payment methods:The Payment method filtering with
The 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.
This approach ensures you only display payment methods that are actually available.
<div slot="payments">
<primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
<primer-payment-method type="PAYPAL"></primer-payment-method>
</div>
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>
Dynamic rendering with events
You can also dynamically render payment methods by listening to theprimer:methods-update event:Example: Dynamic payment method rendering
Example: Dynamic payment method rendering
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);
});
});
Use The
rememberPaymentMethodsController() to access the available payment methods as a reactive StateFlow: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 themethods 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")
}
StateFlow updates automatically when checkout.refresh() is called.Override the Pass the view 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: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 fromsession.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()
}
}
}
}
}
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.Avoiding duplicate card forms
- Web
- Android
- iOS
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>
PAYMENT_CARD payment method in your layout.Filtering to avoid duplicate card forms
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: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);
});
});
On Android, this issue does not apply. If you want to exclude card payments from
PrimerCardForm and PrimerPaymentMethods are separate composables that don’t conflict. When using PrimerCheckoutHost, you compose them independently:PrimerCheckoutHost(checkout) {
Column {
PrimerCardForm(controller = rememberCardFormController(checkout))
PrimerPaymentMethods(rememberPaymentMethodsController(checkout))
}
}
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.Best practices
- 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.Readybefore rendering (Android) - Handle payment failures - Either use the
<primer-error-message-container>component (Web) or match onPrimerCheckoutState.Failurefromcheckout.state(Android)
See also
Error handling
Handle payment failures and display error messages
Events guide
Complete reference for all checkout events