> ## 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.

# Events guide

> 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.

<Tip title="Guide vs Reference">
  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.
</Tip>

## How events work

<Tabs>
  <Tab title="Web">
    Primer Checkout components dispatch [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/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.
  </Tab>

  <Tab title="Android">
    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.
  </Tab>

  <Tab title="iOS">
    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`.
  </Tab>
</Tabs>

## Choosing where to listen

Each platform has its own mechanism for receiving SDK events and state changes.

<Tabs>
  <Tab title="Web">
    <Tabs>
      <Tab title="Component Level">
        Attach listeners directly to the `<primer-checkout>` element. Each listener is scoped to a single checkout instance.

        ```javascript theme={"dark"}
        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.
      </Tab>

      <Tab title="Document Level">
        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.

        ```javascript theme={"dark"}
        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.
      </Tab>
    </Tabs>

    ### Ensuring the component exists

    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.

    ```javascript theme={"dark"}
    // Safe: wait for the DOM to be ready before querying
    document.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`.
  </Tab>

  <Tab title="Android">
    State -- including the terminal `Success` and `Failure` outcomes -- is observed from `checkout.state`:

    ```kotlin theme={"dark"}
    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)
        }
    }
    ```

    ### PrimerCheckoutState

    | State     | Description                                 | Properties                           |
    | --------- | ------------------------------------------- | ------------------------------------ |
    | `Loading` | SDK is initializing, fetching configuration | --                                   |
    | `Ready`   | Checkout is ready for payment               | `clientSession: PrimerClientSession` |
    | `Success` | Payment completed                           | `checkoutData: PrimerCheckoutData`   |
    | `Failure` | Payment failed or an error occurred         | `error: PrimerError`                 |

    ### PrimerClientSession

    Available in the `Ready` state:

    | Property        | Type                    | Description                             |
    | --------------- | ----------------------- | --------------------------------------- |
    | `customerId`    | `String?`               | Customer identifier                     |
    | `orderId`       | `String?`               | Order identifier                        |
    | `currencyCode`  | `String?`               | Currency code (e.g., `"USD"`)           |
    | `totalAmount`   | `Int?`                  | Total amount in minor units (cents)     |
    | `lineItems`     | `List<PrimerLineItem>?` | Order line items                        |
    | `orderDetails`  | `PrimerOrder?`          | Order details (country, shipping)       |
    | `customer`      | `PrimerCustomer?`       | Customer details (email, name, address) |
    | `paymentMethod` | `PrimerPaymentMethod?`  | Payment method configuration            |
    | `fees`          | `List<PrimerFee>?`      | Associated fees                         |
  </Tab>

  <Tab title="iOS">
    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`:

    ```swift theme={"dark"}
    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`.

    ### PrimerCheckoutState

    | State           | Description                   | Associated Values                          |
    | --------------- | ----------------------------- | ------------------------------------------ |
    | `.initializing` | SDK is loading configuration  | --                                         |
    | `.ready`        | Checkout is ready for payment | `totalAmount: Int`, `currencyCode: String` |
    | `.success`      | Payment completed             | `PaymentResult`                            |
    | `.failure`      | Payment failed                | `PrimerError`                              |
    | `.dismissed`    | Checkout was dismissed        | --                                         |
  </Tab>
</Tabs>

## Phase 1: Initialization

Every Primer Checkout integration begins with initialization. The SDK must fully initialize before you can pre-fill form fields or call SDK methods.

<Tabs>
  <Tab title="Web">
    The `primer:ready` event fires once after the SDK has fully initialized. It delivers the `PrimerJS` instance as `event.detail`.

    ```javascript theme={"dark"}
    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 events
    checkout.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.
    });
    ```

    <Warning>
      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.
    </Warning>
  </Tab>

  <Tab title="Android">
    The checkout controller initializes automatically when created with `rememberPrimerCheckoutController()`. Payment outcomes are terminal cases of `checkout.state` -- observe the flow to react to them:

    ```kotlin theme={"dark"}
    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)
    ```
  </Tab>

  <Tab title="iOS">
    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:

    ```swift theme={"dark"}
    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.

    <Warning>
      If you don't provide an `onCompletion` closure, successful and failed payments will complete with no redirect or confirmation. Always provide this closure.
    </Warning>
  </Tab>
</Tabs>

## Phase 2: Payment method discovery

Shortly after initialization, the SDK provides information about available payment methods for the current session.

<Tabs>
  <Tab title="Web">
    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.

    ```javascript theme={"dark"}
    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](/docs/sdk/primer-checkout-web/components/primer-vault-manager#headless-vault-implementation) covers that pattern in detail.
  </Tab>

  <Tab title="Android">
    Payment methods are managed automatically by the SDK components (`PrimerPaymentMethods`, `PrimerVaultedPaymentMethods`). You can access the client session details from the `Ready` state:

    ```kotlin theme={"dark"}
    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)}")
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    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:

    ```swift theme={"dark"}
    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.
  </Tab>
</Tabs>

## Phase 3: User interaction

As the user fills in payment details, the SDK provides real-time feedback.

### Showing the Card Network

<Tabs>
  <Tab title="Web">
    `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.

    ```javascript theme={"dark"}
    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}`);
      }
    });
    ```
  </Tab>

  <Tab title="Android">
    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.
  </Tab>

  <Tab title="iOS">
    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:

    ```swift theme={"dark"}
    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(_:)`.
  </Tab>
</Tabs>

To show a loading indicator while BIN data is being fetched, listen for `primer:bin-data-loading-change`:

```javascript theme={"dark"}
checkout.addEventListener('primer:bin-data-loading-change', (event) => {
  const { loading } = event.detail;
  document.getElementById('card-logo').src = loading
    ? '/images/card-placeholder.svg'
    : document.getElementById('card-logo').src;
});
```

<Note>
  `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](/docs/sdk/primer-checkout-web/events-reference#primerbin-data-available) for the full payload shape.
</Note>

### Triggering Submission from a Custom Button

<Tabs>
  <Tab title="Web">
    If you're using your own "Pay" button instead of the built-in one, dispatch `primer:card-submit` to tell the SDK to validate and submit the card form.

    ```javascript theme={"dark"}
    document.getElementById('custom-pay-button').addEventListener('click', () => {
      document.dispatchEvent(
        new CustomEvent('primer:card-submit', {
          bubbles: true,
          composed: true,
        }),
      );
    });
    ```

    <Warning>
      `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.
    </Warning>

    For vault payment submission from a custom button, dispatch `primer:vault-submit` in the same way. See the [Events Reference and Triggerable Events](/docs/sdk/primer-checkout-web/events-reference#triggerable-events) for the full list of events you can dispatch.
  </Tab>

  <Tab title="Android">
    The Android SDK handles user interaction through Compose components. Card form state is managed via `PrimerCardFormController`:

    ```kotlin theme={"dark"}
    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.
  </Tab>

  <Tab title="iOS">
    If you're building a custom card form UI, drive the fields and submission through the `PrimerCardFormSession` injected into each `PrimerCardForm` slot. Bind your `TextField`s to the `update*` methods and call `submit()` from your custom button:

    ```swift theme={"dark"}
    PrimerCardForm(
      cardDetails: { session in
        VStack(spacing: 16) {
          TextField("Card number", text: Binding(
            get: { session.state.data[.cardNumber] },
            set: { session.updateCardNumber($0) }
          ))
          TextField("Cardholder name", text: Binding(
            get: { session.state.data[.cardholderName] },
            set: { session.updateCardholderName($0) }
          ))
        }
      },
      submitButton: { session in
        // Submit when your custom button is tapped
        Button("Pay") { session.submit() }
          .disabled(!session.state.isValid || session.state.isLoading)
      }
    )
    ```

    Call `session.cancel()` to cancel and return to payment method selection.
  </Tab>
</Tabs>

## Phase 4: Payment processing and outcome

Once the user submits, the SDK moves through a processing to outcome sequence.

<Tabs>
  <Tab title="Web">
    The `primer:state-change` event fires at every step, giving you a single stream to drive your UI state.

    ```javascript theme={"dark"}
    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);
      }
    });
    ```
  </Tab>

  <Tab title="Android">
    | State                         | When              | Key Properties                     |
    | ----------------------------- | ----------------- | ---------------------------------- |
    | `PrimerCheckoutState.Success` | Payment completed | `checkoutData: PrimerCheckoutData` |
    | `PrimerCheckoutState.Failure` | Payment failed    | `error: PrimerError`               |

    ```kotlin theme={"dark"}
    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)
    ```
  </Tab>

  <Tab title="iOS">
    The `onCompletion` closure fires once at the terminal `PrimerCheckoutState` -- `.success`, `.failure`, or `.dismissed`. Read the [`PaymentResult`](/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state) on `.success` and the [`PrimerError`](/docs/sdk/ios-checkout/v3.0.0-beta/api/common/primer-error) on `.failure`:

    ```swift theme={"dark"}
    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.
  </Tab>
</Tabs>

### State change vs. outcome events

`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).

### Event timing

Events may fire multiple times during a single checkout session (for example, if the user retries after a failure).

<Tabs>
  <Tab title="Web">
    * `primer:state-change` fires at every step — use it for continuous UI updates (spinners, button states)
    * `primer:payment-success` and `primer:payment-failure` fire exactly once at the end — use them for final actions
  </Tab>

  <Tab title="Android">
    * `Success` fires immediately — the default success screen auto-dismisses after 3 seconds
    * `Failure` fires immediately — the error screen stays open for retry
  </Tab>

  <Tab title="iOS">
    * Each session's `@Published` state updates as the flow progresses -- observe it for continuous UI updates (spinners, button states)
    * `onCompletion` fires once at the terminal state -- use it for final actions (navigation, confirmation)
    * `.dismissed` fires when the user dismisses the checkout without completing payment
  </Tab>
</Tabs>

### Dismissal and session refresh

<Tabs>
  <Tab title="Web">
    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.
  </Tab>

  <Tab title="Android">
    Handle checkout dismissal via the `onDismiss` callback:

    ```kotlin theme={"dark"}
    PrimerCheckoutSheet(
        checkout = checkout,
        onDismiss = {
            navigateBack()
        },
    )
    ```

    Call `checkout.refresh()` to reinitialize the checkout after updating the order on your server.

    Use `checkout.formatAmount()` to display currency-formatted values:

    ```kotlin theme={"dark"}
    val formattedTotal = checkout.formatAmount(1000) // "$10.00"
    ```
  </Tab>

  <Tab title="iOS">
    Handle dismissal by observing the `.dismissed` state in `onCompletion`:

    ```swift theme={"dark"}
    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).
  </Tab>
</Tabs>

## Intercepting payments with primer:payment-start

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.

<Tabs>
  <Tab title="Web">
    The `primer:payment-start` event gives you that interception point.

    ```javascript theme={"dark"}
    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();
    });
    ```

    <Warning>
      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.
    </Warning>
  </Tab>

  <Tab title="Android">
    <Note>Payment interception is not available in the composable module.</Note>
  </Tab>

  <Tab title="iOS">
    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:

    ```swift theme={"dark"}
    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.
        }
      }
    }
    ```

    <Warning>
      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.
    </Warning>
  </Tab>
</Tabs>

`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](/docs/sdk/primer-checkout-web/events-reference#preparehandler) for the full type definition.

<Tip>
  `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](/docs/checkout/primer-checkout/guides-and-recipes/manual-payment-approval) flow, which pauses on the `primer:payment-approval-required` event until your backend approves or aborts the attempt.
</Tip>

## Working with Vaulted Payment Methods

If your integration supports saved (vaulted) payment methods, additional events become relevant.

<Tabs>
  <Tab title="Web">
    * **`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.

    ```javascript theme={"dark"}
    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](/docs/sdk/primer-checkout-web/components/primer-vault-manager#headless-vault-implementation).
  </Tab>

  <Tab title="Android">
    Vaulted payment methods are managed by the `PrimerVaultedPaymentMethods` component and `rememberVaultedPaymentMethodsController()`:

    ```kotlin theme={"dark"}
    val vaultController = rememberVaultedPaymentMethodsController()
    ```

    The SDK provides built-in UI for displaying and selecting saved payment methods within `PrimerCheckoutSheet`.
  </Tab>

  <Tab title="iOS">
    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:

    ```swift theme={"dark"}
    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.
  </Tab>
</Tabs>

## Event flow overview

The following diagram shows the typical sequence of events in a successful card payment, from initialization through confirmation.

```mermaid theme={"dark"}
sequenceDiagram
    participant App as Your App
    participant SDK as Primer SDK

    Note over App,SDK: Phase 1 - Initialization
    SDK->>App: SDK ready
    Note right of App: Register callbacks, pre-fill fields

    Note over App,SDK: Phase 2 - Discovery
    SDK->>App: Payment methods available
    SDK->>App: Vaulted methods loaded
    Note right of App: Render payment method UI

    Note over App,SDK: Phase 3 - User Interaction
    App->>SDK: User enters card details
    SDK->>App: Card network detected
    Note right of App: Show card brand logo

    Note over App,SDK: Phase 4 - Processing & Outcome
    App->>SDK: Submit payment
    SDK->>App: State: processing
    Note right of App: Show spinner, disable button
    SDK->>App: State: successful
    SDK->>App: Payment success event
    Note right of App: Navigate to confirmation
```

## Debugging tips

<Tabs>
  <Tab title="Web">
    * **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.
  </Tab>

  <Tab title="Android">
    * **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.
  </Tab>

  <Tab title="iOS">
    * **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.
  </Tab>
</Tabs>

## See also

<CardGroup cols={2}>
  <Card title="Web events reference" icon="book" href="/docs/sdk/primer-checkout-web/events-reference">
    Every Web event name, payload type, callback signature, and instance method
  </Card>

  <Card title="Android SDK reference" icon="android" href="/docs/sdk/android-checkout/v3.0.0-beta/api/overview">
    Android SDK API documentation
  </Card>

  <Card title="Recipes" icon="utensils" href="/docs/checkout/primer-checkout/guides-and-recipes/overview">
    Use-case driven examples for common integration scenarios
  </Card>

  <Card title="Error handling" icon="triangle-exclamation" href="/docs/checkout/primer-checkout/build-your-ui/error-handling">
    Deep dive into error handling, retries, and failure recovery
  </Card>
</CardGroup>
