Learn when, why, and how to use Primer Checkout events to build robust payment flows
This guide explains the event-driven model behind Primer Checkout and walks through the integration decisions you’ll face when wiring events into your application. It is organized around the questions you’ll encounter as you build: where to listen, what to set up first, and how to handle each phase of the payment lifecycle.
This guide focuses on when and why to use each event. For the complete API surface, every event name, payload shape, type definition, and callback signature see the platform-specific SDK references.
Primer Checkout components dispatch CustomEvent objects. Every Primer event is created with bubbles: true and composed: true, which means events propagate up through shadow DOM boundaries and can be caught at any ancestor element, including document. This design gives you two choices for where to listen, covered in the next section.
The Android SDK communicates through a single channel:
State (PrimerCheckoutState): the full checkout lifecycle — loading, ready, and the terminal Success/Failure outcomes
State is a StateFlow<PrimerCheckoutState> you observe in Compose via checkout.state. Payment outcomes are terminal cases of that same flow.
The iOS SDK communicates through two mechanisms:
State (PrimerCheckoutState): Lifecycle and outcome states — initializing, ready, success, failure, dismissed
onCompletion callback: Fires once when the checkout reaches a terminal state
For the managed modal, pass onCompletion to PrimerCheckout. For inline, composable UI, observe each session’s @Published state inside your SwiftUI views and receive the terminal outcome through the .primerCheckoutSession(_:onCompletion:) modifier. In UIKit, conform to PrimerCheckoutPresenterDelegate.
Each platform has its own mechanism for receiving SDK events and state changes.
Web
Android
iOS
Component Level
Document Level
Attach listeners directly to the <primer-checkout> element. Each listener is scoped to a single checkout instance.
const checkout = document.querySelector('primer-checkout');checkout.addEventListener('primer:ready', (event) => { // This listener only fires for this checkout instance const primer = event.detail; configureCallbacks(primer);});
Choose this when:
You want to co-locate event logic with the component that owns it, such as inside a framework component’s lifecycle hook.
You need to tear down listeners cleanly when a checkout is removed from the DOM.
Attach listeners to document. Because Primer events bubble and are composed, they reach the document root regardless of where the component sits in the DOM.
document.addEventListener('primer:payment-success', (event) => { // Fires when any Primer event bubbles up to document analytics.track('payment_completed', event.detail);});
Choose this when:
You have a single checkout per page and prefer centralized event handling.
You’re building cross-cutting concerns like analytics, logging, or error monitoring.
Your architecture already uses a global event bus pattern.
When using component-level listeners, make sure the element is in the DOM before attaching them. If your checkout renders dynamically (through a router transition, a conditional template, or lazy loading), the element may not exist at script-execution time.
// Safe: wait for the DOM to be ready before queryingdocument.addEventListener('DOMContentLoaded', () => { const checkout = document.querySelector('primer-checkout'); if (checkout) { checkout.addEventListener('primer:payment-success', handlePaymentSuccess); checkout.addEventListener('primer:payment-failure', handlePaymentFailure); }});
If your framework provides a lifecycle hook that runs after the component mounts (React’s useEffect, Vue’s onMounted, etc.), prefer that over DOMContentLoaded.
State — including the terminal Success and Failure outcomes — is observed from checkout.state:
val checkout = rememberPrimerCheckoutController(clientToken)val state by checkout.state.collectAsStateWithLifecycle()when (state) { is PrimerCheckoutState.Loading -> { CircularProgressIndicator() } is PrimerCheckoutState.Ready -> { val clientSession = (state as PrimerCheckoutState.Ready).clientSession Text("Total: ${checkout.formatAmount(clientSession.totalAmount ?: 0)}") PrimerCheckoutSheet(checkout = checkout) }}
The terminal outcome is delivered to the onCompletion closure on PrimerCheckout (managed modal) or on the .primerCheckoutSession(_:onCompletion:) modifier (inline). onCompletion fires exactly once with a terminal PrimerCheckoutState — .success, .failure, or .dismissed:
PrimerCheckout(clientToken: clientToken) { state in switch state { case let .success(result): print("Payment ID: \(result.paymentId)") case let .failure(error): print("Failed: \(error.errorDescription ?? "")") case .dismissed: print("Dismissed") default: // .initializing and .ready are lifecycle states and are // never delivered to onCompletion. break }}
Lifecycle states (.initializing, .ready) are driven internally by the SDK and never delivered to onCompletion. For inline UI you observe lifecycle and field-level progress by reading each session’s @Published property: PrimerCheckoutSession publishes phase, while PrimerCardFormSession and PrimerSelectionSession publish state.
Every Primer Checkout integration begins with initialization. The SDK must fully initialize before you can pre-fill form fields or call SDK methods.
Web
Android
iOS
The primer:ready event fires once after the SDK has fully initialized. It delivers the PrimerJS instance as event.detail.
const checkout = document.querySelector('primer-checkout');checkout.addEventListener('primer:ready', (event) => { const primer = event.detail; // Pre-fill known data (optional) const user = getAuthenticatedUser(); if (user?.fullName) { primer.setCardholderName(user.fullName); } // Access available methods immediately if needed const methods = primer.getPaymentMethods(); console.log(`${methods.length} payment method(s) available at init`);});// Handle payment outcomes via eventscheckout.addEventListener('primer:payment-success', (event) => { const { payment, paymentMethodType } = event.detail; console.log('Payment successful:', payment.last4Digits); window.location.href = `/confirmation?method=${paymentMethodType}`;});checkout.addEventListener('primer:payment-failure', (event) => { const { error } = event.detail; console.error('Payment failed:', error.message); // The checkout UI already displays the error to the user. // Use this event for logging, analytics, or retry logic.});
If you don’t listen for primer:payment-success and primer:payment-failure, successful and failed payments will complete silently with no redirect or confirmation. Always attach these event listeners.
The checkout controller initializes automatically when created with rememberPrimerCheckoutController(). Payment outcomes are terminal cases of checkout.state — observe the flow to react to them:
val state by checkout.state.collectAsStateWithLifecycle()LaunchedEffect(state) { when (val s = state) { is PrimerCheckoutState.Success -> { val payment = s.checkoutData Log.d("Checkout", "Payment ID: ${payment.payment.id}") navigateToConfirmation(payment) } is PrimerCheckoutState.Failure -> { val error = s.error Log.e("Checkout", "Failed: ${error.description}") Log.e("Checkout", "Diagnostics: ${error.diagnosticsId}") } else -> Unit }}PrimerCheckoutSheet(checkout = checkout)
The checkout initializes automatically when the PrimerCheckout SwiftUI view appears (or when the .primerCheckoutSession modifier calls start() on appear). Pass an onCompletion closure to receive the final result:
PrimerCheckout(clientToken: clientToken) { state in switch state { case let .success(result): print("Payment ID: \(result.paymentId)") navigateToConfirmation(result) case let .failure(error): print("Failed: \(error.errorDescription ?? "")") print("Diagnostics: \(error.diagnosticsId)") case .dismissed: navigateBack() default: // .initializing and .ready are lifecycle states and are // never delivered to onCompletion. break }}
To observe the .ready lifecycle state for an inline integration, read the phase published by PrimerCheckoutSession (.initializing / .ready) inside your SwiftUI view. The totalAmount / currencyCode values are associated with PrimerCheckoutState.ready and are not exposed on the inline session.
If you don’t provide an onCompletion closure, successful and failed payments will complete with no redirect or confirmation. Always provide this closure.
Shortly after initialization, the SDK provides information about available payment methods for the current session.
Web
Android
iOS
The SDK dispatches primer:methods-update with the list of payment methods available for the current session. This event fires once on load and may fire again if the session changes (e.g. after calling primer.refreshSession() in response to a cart update).You should use this event when you want to build a custom payment method selector, conditionally render UI based on what’s available, or route users to a specific method.
checkout.addEventListener('primer:methods-update', (event) => { const methods = event.detail; // Conditionally show an "Express Checkout" section only if Apple Pay or Google Pay is available const hasExpressMethod = methods.toArray().some( (m) => m.type === 'APPLE_PAY' || m.type === 'GOOGLE_PAY' ); document.getElementById('express-checkout-section').hidden = !hasExpressMethod;});
If you’re building a fully custom payment method layout (headless), you’ll also iterate over the methods to create <primer-payment-method> elements dynamically. The Headless Vault Guide covers that pattern in detail.
Payment methods are managed automatically by the SDK components (PrimerPaymentMethods, PrimerVaultedPaymentMethods). You can access the client session details from the Ready state:
val state by checkout.state.collectAsStateWithLifecycle()when (state) { is PrimerCheckoutState.Ready -> { val clientSession = (state as PrimerCheckoutState.Ready).clientSession Text("Total: ${checkout.formatAmount(clientSession.totalAmount ?: 0)}") }}
Payment methods are managed by the PrimerPaymentMethods view, which resolves a PrimerSelectionSession from the environment. Observe the session’s published state to track available and selected methods:
struct MethodCount: View { @ObservedObject var session: PrimerSelectionSession var body: some View { VStack(alignment: .leading) { Text("Methods: \(session.state.paymentMethods.count)") if let selected = session.state.selectedPaymentMethod { Text("Selected: \(selected.name)") } } }}
Read state inside a header or emptyState slot, which receive the PrimerSelectionSession. The method slot instead receives a CheckoutPaymentMethod and an onSelect closure, so it does not have access to the session.
primer:bin-data-available fires as the user types their card number. It provides the detected card network, co-badged network alternatives, and additional BIN data such as issuer information when available. Use it to display the correct card brand logo, show a co-badge network picker, or adjust UI based on card attributes.
checkout.addEventListener('primer:bin-data-available', (event) => { const { preferred, alternatives, status } = event.detail; const logoEl = document.getElementById('card-logo'); const cobrandEl = document.getElementById('cobrand-selector'); if (preferred) { logoEl.src = `/images/${preferred.network.toLowerCase()}.svg`; logoEl.alt = preferred.displayName; } // Some cards support co-badging (e.g. Carte Bancaire / Visa). // If there are multiple allowed networks, show a picker. const selectableNetworks = [preferred, ...alternatives].filter(n => n?.allowed); cobrandEl.hidden = selectableNetworks.length <= 1; // When status is 'complete', additional issuer data is available if (status === 'complete' && preferred?.issuerCountryCode) { console.log(`Card issued in: ${preferred.issuerCountryCode}`); }});
The Android SDK handles card network detection and display automatically within the PrimerCardForm component. Card brand icons update in real-time as the user types.
Card network detection is available via PrimerCardFormState. The PrimerCardForm view injects a PrimerCardFormSession into each of its slots; observe the session’s published state and read selectedNetwork and availableNetworks as the user types:
PrimerCardForm(cardDetails: { session in VStack(alignment: .leading) { CardFormDefaults.cardNumber(session) if let network = session.state.selectedNetwork { Text("Detected network: \(network.displayName)") } // Co-badged cards expose multiple networks if session.state.availableNetworks.count > 1 { Text("Co-badge options: \(session.state.availableNetworks.map(\.displayName).joined(separator: ", "))") } }})
To select a network on a co-badged card, call session.selectCardNetwork(_:).
To show a loading indicator while BIN data is being fetched, listen for primer:bin-data-loading-change:
primer:bin-data-available replaces the older primer:card-network-change event with a richer payload that includes issuer details and card attributes. See the Events Reference for the full payload shape.
bubbles: true and composed: true are required so the event crosses shadow DOM boundaries and reaches the card form component inside <primer-checkout>. Omitting either option will silently prevent submission.
For vault payment submission from a custom button, dispatch primer:vault-submit in the same way. See the Events Reference and Triggerable Events for the full list of events you can dispatch.
The Android SDK handles user interaction through Compose components. Card form state is managed via PrimerCardFormController:
val cardFormController = rememberCardFormController()
The SDK provides built-in components for card input (PrimerCardForm), payment method selection (PrimerPaymentMethods), and vaulted methods (PrimerVaultedPaymentMethods). These handle user interaction automatically.
If you’re building a custom card form UI, drive the fields and submission through the PrimerCardFormSession injected into each PrimerCardForm slot. Bind your TextFields to the update* methods and call submit() from your custom button:
Once the user submits, the SDK moves through a processing to outcome sequence.
Web
Android
iOS
The primer:state-change event fires at every step, giving you a single stream to drive your UI state.
checkout.addEventListener('primer:state-change', (event) => { const { isLoading, isProcessing, isSuccessful, paymentFailure, primerJsError } = event.detail; const submitBtn = document.getElementById('custom-pay-button'); const spinner = document.getElementById('loading-overlay'); // Disable the button during loading or processing submitBtn.disabled = isLoading || isProcessing; // Show a loading overlay while the payment is in flight spinner.hidden = !isProcessing; if (isSuccessful) { // Payment succeeded — primer:payment-success will also fire document.getElementById('checkout-form').hidden = true; } if (paymentFailure) { // The checkout component already displays the error to the user. // Use this for supplementary logging or analytics. console.error(`Payment error [${paymentFailure.code}]: ${paymentFailure.message}`); } if (primerJsError) { // An SDK-level error (network failure, configuration issue, etc.) // Unlike paymentFailure, this may not be shown in the checkout UI. reportToErrorService(primerJsError); }});
State
When
Key Properties
PrimerCheckoutState.Success
Payment completed
checkoutData: PrimerCheckoutData
PrimerCheckoutState.Failure
Payment failed
error: PrimerError
val state by checkout.state.collectAsStateWithLifecycle()LaunchedEffect(state) { when (val s = state) { is PrimerCheckoutState.Success -> { val payment = s.checkoutData Log.d("Checkout", "Payment ID: ${payment.payment.id}") navigateToConfirmation(payment) } is PrimerCheckoutState.Failure -> { val error = s.error Log.e("Checkout", "Failed: ${error.description}") Log.e("Checkout", "Diagnostics: ${error.diagnosticsId}") } else -> Unit }}PrimerCheckoutSheet(checkout = checkout)
The onCompletion closure fires once at the terminal PrimerCheckoutState — .success, .failure, or .dismissed. Read the PaymentResult on .success and the PrimerError on .failure:
PrimerCheckout(clientToken: clientToken) { state in switch state { case let .success(result): print("Payment ID: \(result.paymentId)") print("Status: \(result.status)") navigateToConfirmation(result) case let .failure(error): print("Error: \(error.errorDescription ?? "")") print("Diagnostics: \(error.diagnosticsId)") case .dismissed: break default: // .initializing and .ready are lifecycle states and are // never delivered to onCompletion. break }}
For an inline integration, drive intermediate UI (spinners, disabled buttons) from each session’s @Published state — for example PrimerCardFormSession.state.isLoading while a payment is in flight.
primer:state-change fires multiple times during a single payment. The outcome events (primer:payment-success, primer:payment-failure) fire exactly once at the end. Use primer:state-change for continuous UI updates (spinners, button states, progress indicators). Use the outcome events for final actions (redirects, confirmations, server notifications).
Call primer.refreshSession() to sync the client-side SDK with your server session after a cart change. The SDK will re-dispatch primer:methods-update with the updated method list.
Handle checkout dismissal via the onDismiss callback:
Call checkout.refresh() to reinitialize the checkout after updating the order on your server.Use checkout.formatAmount() to display currency-formatted values:
val formattedTotal = checkout.formatAmount(1000) // "$10.00"
Handle dismissal by observing the .dismissed state in onCompletion:
PrimerCheckout(clientToken: clientToken) { state in if case .dismissed = state { navigateBack() }}
For an inline integration, call await session.refresh() on your PrimerCheckoutSession to re-fetch configuration and payment methods after a client-session update (for example, an amount or currency change).
Sometimes you need to run a check after the user clicks “Pay” but before the payment is created — for example, confirming terms-of-service acceptance, validating inventory, or applying a last-second promotion code.
Web
Android
iOS
The primer:payment-start event gives you that interception point.
checkout.addEventListener('primer:payment-start', (event) => { const { paymentMethodType, continuePaymentCreation, abortPaymentCreation } = event.detail; // Prevent automatic continuation event.preventDefault(); const termsAccepted = document.getElementById('terms-checkbox').checked; if (!termsAccepted) { showInlineError('Please accept the terms of service before paying.'); abortPaymentCreation(); return; } // Optionally inspect paymentMethodType to apply method-specific logic continuePaymentCreation();});
If you call event.preventDefault(), you must call either continuePaymentCreation() or abortPaymentCreation() in every code path. If neither is called, the payment will hang indefinitely. If you don’t call preventDefault(), the payment continues automatically.
Payment interception is not available in the composable module.
Set onBeforePaymentCreate on your PrimerCheckoutSession to intercept payments after the user taps “Pay” but before the payment is created. The handler receives the payment-method data and a decision handler:
struct CheckoutView: View { @StateObject private var session: PrimerCheckoutSession init(clientToken: String) { let session = PrimerCheckoutSession(clientToken: clientToken) session.onBeforePaymentCreate = { _, decisionHandler in guard termsAccepted else { decisionHandler(.abortPaymentCreation()) return } decisionHandler(.continuePaymentCreation(withIdempotencyKey: nil)) } _session = StateObject(wrappedValue: session) } var body: some View { ScrollView { PrimerCardForm() } .primerCheckoutSession(session) { state in // Handle the terminal state. } }}
You must call the decisionHandler closure with either .continuePaymentCreation(withIdempotencyKey:) or .abortPaymentCreation() in every code path. If neither is called, the payment will hang indefinitely.
continuePaymentCreation accepts an optional { idempotencyKey } parameter. When provided, this key is sent with the payment request to prevent duplicate payments from being created. See the Events Reference for the full type definition.
primer:payment-start is for client-side checks that resolve in the browser. If you need a server-side decision (running a fraud check or inventory validation on your backend before the payment is created), use the Manual payment approval flow, which pauses on the primer:payment-approval-required event until your backend approves or aborts the attempt.
If your integration supports saved (vaulted) payment methods, additional events become relevant.
Web
Android
iOS
primer:vault-methods-update — fires when vaulted payment methods are loaded or when the vault state changes. Use it to render saved cards, show a “Pay with saved card” section, or determine whether CVV re-entry is required.
primer:vault-selection-change — fires when the user selects or deselects a saved method. Use it to enable or disable your submit button, or to toggle between “pay with saved card” and “pay with new card” views.
let cvvRequired = false;checkout.addEventListener('primer:vault-methods-update', (event) => { const { vaultedPayments, cvvRecapture } = event.detail; cvvRequired = cvvRecapture; const savedMethodsSection = document.getElementById('saved-methods'); if (vaultedPayments.length > 0) { savedMethodsSection.hidden = false; renderSavedMethodsList(vaultedPayments); } else { savedMethodsSection.hidden = true; }});checkout.addEventListener('primer:vault-selection-change', (event) => { const { paymentMethodId } = event.detail; const vaultSubmitBtn = document.getElementById('vault-pay-button'); // Enable the button only when a method is selected vaultSubmitBtn.disabled = !paymentMethodId; // If CVV re-entry is required, show the CVV input when a method is selected if (paymentMethodId && cvvRequired) { showCvvInput(paymentMethodId); }});
For full headless vault implementation, including primer.vault.createCvvInput(), primer.vault.startPayment(), and primer.vault.delete() see the Headless Vault Guide.
Vaulted payment methods are managed by the PrimerVaultedPaymentMethods component and rememberVaultedPaymentMethodsController():
val vaultController = rememberVaultedPaymentMethodsController()
The SDK provides built-in UI for displaying and selecting saved payment methods within PrimerCheckoutSheet.
Saved methods are rendered by the PrimerVaultedPaymentMethods view, backed by the same PrimerSelectionSession. Read vaultedPaymentMethods for the list and call selectVaulted(_:) to choose one; observe state for CVV-recapture requirements:
struct SavedMethodRow: View { @ObservedObject var session: PrimerSelectionSession let method: PrimerHeadlessUniversalCheckout.VaultedPaymentMethod var body: some View { VStack(alignment: .leading, spacing: 8) { Button("Use \(method.paymentMethodType)") { session.selectVaulted(method) } if session.state.requiresCvvInput, session.state.selectedVaultedPaymentMethod?.id == method.id { SecureField("CVV", text: Binding( get: { session.state.cvvInput }, set: { session.updateCvvInput($0) } )) } } }}
Call session.delete(_:) to remove a saved method and session.showAll() to navigate to the full list.
Events not firing? Confirm the <primer-checkout> element is in the DOM before you add listeners. In SPAs, race conditions between route rendering and listener attachment are the most common cause.
Shadow DOM boundary issues? Triggerable events (primer:card-submit, primer:vault-submit) must be dispatched with bubbles: true and composed: true or they won’t reach the internal form component.
Stale data after a cart change? Call primer.refreshSession() to sync the client-side SDK with your server session. The SDK will re-dispatch primer:methods-update with the updated method list.
State not updating? Make sure you’re using collectAsStateWithLifecycle() instead of collectAsState().
Outcomes not received? Ensure you’re observing checkout.state and handling the terminal PrimerCheckoutState.Success and PrimerCheckoutState.Failure cases.
Stale data after a cart change? Call checkout.refresh() to reinitialize the checkout.
Log diagnostics IDs. Every PrimerError includes a diagnosticsId. Log it for troubleshooting with Primer support.
State not updating? Observe the session with @ObservedObject (or hold PrimerCheckoutSession as a @StateObject) so SwiftUI re-renders when state changes.
onCompletion not firing? It only fires for terminal states (.success, .failure, .dismissed). The lifecycle states .initializing and .ready are never delivered to onCompletion — read PrimerCheckoutSession.phase for those.
Composable views not rendering? Make sure the .primerCheckoutSession(_:onCompletion:) modifier is applied above PrimerCardForm / PrimerPaymentMethods in the hierarchy. Without it, they cannot resolve a session from the environment.
Child sessions are nil?PrimerCheckoutSession.cardForm and .selection are non-nil only once phase == .ready.
Log diagnostics IDs. Every PrimerError includes a diagnosticsId. Log it for troubleshooting with Primer support.