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

# Migrate from Drop-in or Headless

> Complete SDK migration for Web, iOS and Android: package changes, the new initialization pattern, callbacks replaced by events, and CSS-based theming.

This guide covers the key changes when migrating from the legacy SDK to the new Primer Checkout Components. Select your platform to see platform-specific migration steps.

> [!NOTE]
>
> Not every section will apply — skip sections for features you don't use.

***

## Package and imports

> [tab] Web
>
> For Web-specific framework patterns (React, Vue, Svelte, Angular), see the [Framework Migration Guide](https://primer.io/docs/checkout/primer-checkout/migration/frameworks.md).
>
> ### Installation
>
> ```bash theme={"dark"}
> # Remove legacy package
> npm uninstall @primer-io/checkout-web
>
> # Install new package
> npm install @primer-io/primer-js
> ```
>
> ### Import changes
>
> **Legacy:**
>
> ```javascript theme={"dark"}
> import { Primer as PrimerSDK } from '@primer-io/checkout-web';
> ```
>
> **New:**
>
> ```javascript theme={"dark"}
> import { loadPrimer } from '@primer-io/primer-js';
> ```
>
> ### Type imports
>
> If you use TypeScript, the type names have changed:
>
> ```typescript theme={"dark"}
> import type {
>   CheckoutElement,
>   PrimerCheckoutOptions,
>   PrimerJS,
> } from '@primer-io/primer-js';
> ```
>
> | Legacy type | New type | Purpose |
> | - | - | - |
> | `PrimerCheckout` | `PrimerJS` | Main SDK instance |
> | `UniversalCheckoutOptions` | `PrimerCheckoutOptions` | Checkout configuration |
> | N/A | `CheckoutElement` | Web component element type |

> [tab] Android
>
> > [!WARNING]
> >
> > The Android SDK is currently in **beta** (v3.0.0-beta.8). The API is subject to change before stable release.
>
> ### No dependency changes required
>
> Primer Checkout Components is part of the same SDK. No BOM or artifact changes are needed.
>
> ### Jetpack Compose requirement
>
> If your project doesn't already use Jetpack Compose, enable it in your module-level `build.gradle.kts`:
>
> ```kotlin theme={"dark"}
> android {
>     buildFeatures {
>         compose = true
>     }
>     composeOptions {
>         kotlinCompilerExtensionVersion = "1.5.8"
>     }
> }
>
> dependencies {
>     implementation(platform("androidx.compose:compose-bom:2025.12.00"))
>     implementation("androidx.compose.ui:ui")
>     implementation("androidx.compose.material3:material3")
>     implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
> }
> ```
>
> ### Import
>
> ```kotlin theme={"dark"}
> import io.primer.android.checkout.*
> ```

> [tab] iOS
>
> > [!WARNING]
> >
> > The iOS SDK is currently in **beta** (v3.0.0-beta.8). The API is subject to change before stable release.
>
> ### Update the dependency version
>
> Primer Checkout is part of the same `PrimerSDK` package, but only in the 3.0 beta line. Pin the dependency to `3.0.0-beta.8`:
>
> * **CocoaPods**: `pod 'PrimerSDK', '3.0.0-beta.8'`
> * **Swift Package Manager**: set the dependency rule to **Exact Version** `3.0.0-beta.8`
>
> ### Minimum deployment target
>
> Update your minimum deployment target from iOS 13.0 to **iOS 15.0**:
>
> * **Xcode**: Target > General > Minimum Deployments > iOS 15.0
> * **Podfile**: `platform :ios, '15.0'`
> * **Package.swift**: `.iOS(.v15)`
>
> The whole `PrimerSDK` package requires iOS 15.0, through both CocoaPods and Swift Package Manager. This includes Drop-In and Headless, so an app that uses them alongside Primer Checkout cannot keep a lower deployment target.
>
> ### Import
>
> The import stays the same:
>
> ```swift theme={"dark"}
> import PrimerSDK
> ```

***

## Initialization

> [tab] Web
>
> The initialization pattern changes from a single function call to a multi-step process involving a Web Component and event listeners.
>
> ### Legacy pattern
>
> ```javascript theme={"dark"}
> import { Primer as PrimerSDK } from '@primer-io/checkout-web';
>
> const checkout = await PrimerSDK.showUniversalCheckout(clientToken, {
>   container: '#checkout-container',
>   onCheckoutComplete: (data) => { /* ... */ },
>   onCheckoutFail: (error, data, handler) => { /* ... */ },
> });
> ```
>
> ### New pattern
>
> ```javascript theme={"dark"}
> import { loadPrimer } from '@primer-io/primer-js';
>
> // 1. Load the SDK (registers the <primer-checkout> web component)
> loadPrimer();
>
> // 2. Get or create the checkout element
> const checkout = document.querySelector('primer-checkout');
>
> // 3. Set the client token (component property — use setAttribute)
> checkout.setAttribute('client-token', clientToken);
>
> // 4. Set SDK options (object — assign directly)
> checkout.options = {
>   locale: 'en-GB',
> };
>
> // 5. Listen for payment events
> checkout.addEventListener('primer:payment-success', (event) => {
>   const { payment, paymentMethodType } = event.detail;
>   window.location.href = `/confirmation?order=${payment.orderId}`;
> });
>
> checkout.addEventListener('primer:payment-failure', (event) => {
>   const { error } = event.detail;
>   console.error('Payment failed:', error.message);
> });
> ```
>
> Or declare the element in HTML:
>
> ```html theme={"dark"}
> <primer-checkout client-token="your-client-token"></primer-checkout>
> ```
>
> ### Key differences
>
> | Aspect | Legacy | New |
> | - | - | - |
> | Entry point | `PrimerSDK.showUniversalCheckout()` | `loadPrimer()` + `<primer-checkout>` element |
> | Container | `container: '#checkout-container'` (CSS selector) | The `<primer-checkout>` element *is* the container |
> | Configuration | Single options object passed to function | Two types: component properties (`setAttribute`) and SDK `options` (direct assignment) |
> | Payment handling | Callbacks in options object | DOM events (`primer:payment-success`, `primer:payment-failure`) |
>
> > [!WARNING]
> >
> > **Component properties vs SDK options** — these are different and cannot be mixed.
> >
> > * **Component properties** (`client-token`, `custom-styles`, `loader-disabled`): Use `setAttribute()`.
> > * **SDK options** (`locale`, `card`, `vault`, `applePay`, etc.): Assign directly to `.options`.
> >
> > Using `setAttribute('options', ...)` will not work. See the [SDK Options guide](https://primer.io/docs/checkout/primer-checkout/configuration/sdk-options.md) for details.

> [tab] Android
>
> The initialization changes from configuring a singleton to creating a Compose-based controller and rendering a checkout sheet.
>
> ### Legacy pattern (Drop-In)
>
> ```kotlin theme={"dark"}
> // Configure singleton + set listener
> Primer.instance.configure(
>     settings = PrimerSettings(),
>     listener = checkoutListener,
> )
>
> // Present checkout
> Primer.instance.showUniversalCheckout(context, clientToken)
> ```
>
> ### New pattern
>
> ```kotlin theme={"dark"}
> @Composable
> fun CheckoutScreen(clientToken: String) {
>     // 1. Create checkout controller
>     val checkout = rememberPrimerCheckoutController(
>         clientToken = clientToken,
>         settings = PrimerSettings(),
>     )
>
>     // 2. Observe outcomes via checkout.state
>     val state by checkout.state.collectAsStateWithLifecycle()
>     LaunchedEffect(state) {
>         when (val s = state) {
>             is PrimerCheckoutState.Success -> {
>                 val paymentId = s.checkoutData.payment.id
>                 navigateToConfirmation(paymentId)
>             }
>             is PrimerCheckoutState.Failure -> {
>                 Log.e("Checkout", "Failed: ${s.primerError.description}")
>             }
>             else -> Unit
>         }
>     }
>
>     // 3. Render checkout sheet
>     PrimerCheckoutSheet(
>         checkout = checkout,
>         onDismiss = { navigateBack() },
>     )
> }
> ```
>
> For inline layouts instead of a modal sheet, use `PrimerCheckoutHost`:
>
> ```kotlin theme={"dark"}
> PrimerCheckoutHost(checkout = checkout) {
>     // Your custom Compose layout
> }
> ```
>
> ### Key differences
>
> | Aspect | Drop-In | CheckoutComponents |
> | - | - | - |
> | Entry point | `Primer.instance.showUniversalCheckout()` | `rememberPrimerCheckoutController()` + `PrimerCheckoutSheet()` |
> | Configuration | `Primer.instance.configure(settings, listener)` | `PrimerSettings` param on controller |
> | Result handling | `PrimerCheckoutListener` callbacks | `PrimerCheckoutState.Success` / `.Failure` via `checkout.state` |
> | UI framework | Android Views | Jetpack Compose |
> | Presentation | Automatic (Activity) | `PrimerCheckoutSheet` (modal) or `PrimerCheckoutHost` (inline) |
> | State | Listener callbacks | `StateFlow<PrimerCheckoutState>` |
> | Dismiss | `Primer.instance.dismiss()` | `checkout.dismiss()` |

> [tab] iOS
>
> The initialization changes from configuring a singleton to creating a view (SwiftUI) or calling a static presenter method (UIKit). Configuration, delegate, and presentation are combined into a single step.
>
> ### Legacy pattern (Drop-In)
>
> ```swift theme={"dark"}
> // Configure singleton + set delegate
> Primer.shared.configure(settings: settings, delegate: self)
>
> // Present checkout
> Primer.shared.showUniversalCheckout(clientToken: clientToken)
> ```
>
> ### New pattern — UIKit
>
> For UIKit apps, `PrimerCheckoutPresenter` provides the most direct migration path. It wraps the SwiftUI checkout in a `UIHostingController` and handles presentation.
>
> ```swift theme={"dark"}
> // Set delegate
> PrimerCheckoutPresenter.shared.delegate = self
>
> // Present checkout — minimal
> PrimerCheckoutPresenter.presentCheckout(clientToken: clientToken, from: self)
>
> // Or with settings
> PrimerCheckoutPresenter.presentCheckout(
>   clientToken: clientToken,
>   from: self,
>   primerSettings: PrimerSettings(paymentHandling: .auto)
> )
>
> // Or with settings and theme
> PrimerCheckoutPresenter.presentCheckout(
>   clientToken: clientToken,
>   from: self,
>   primerSettings: PrimerSettings(paymentHandling: .auto),
>   primerTheme: PrimerCheckoutTheme()
> )
> ```
>
> `PrimerCheckoutPresenter` also provides:
>
> * `PrimerCheckoutPresenter.isPresenting` — check if checkout is currently visible
> * `PrimerCheckoutPresenter.dismiss()` — programmatically dismiss the checkout
>
> ### New pattern — SwiftUI
>
> For SwiftUI apps, use the managed `PrimerCheckout` view directly. It renders Primer's default screens end to end and reports the outcome through `onCompletion`:
>
> ```swift theme={"dark"}
> PrimerCheckout(
>   clientToken: clientToken,
>   primerSettings: PrimerSettings(paymentHandling: .auto),
>   primerTheme: PrimerCheckoutTheme(),
>   onCompletion: { state in
>     switch state {
>     case .success(let result):
>       print("Payment succeeded: \(result.paymentId)")
>     case let .failure(error, _):
>       print("Payment failed: \(error.errorId)")
>     case .dismissed:
>       print("Dismissed")
>     default:
>       break
>     }
>   }
> )
> ```
>
> To build a custom layout instead of the managed screens, hold a `PrimerCheckoutSession` as a `@StateObject`, place the composable views (`PrimerCardForm`, `PrimerPaymentMethods`) in your hierarchy, and attach the `.primerCheckoutSession(_:theme:onCompletion:)` modifier:
>
> ```swift theme={"dark"}
> struct CustomCheckoutView: View {
>   @StateObject private var session: PrimerCheckoutSession
>
>   init(clientToken: String) {
>     _session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken))
>   }
>
>   var body: some View {
>     ScrollView {
>       PrimerPaymentMethods()
>       PrimerCardForm()
>     }
>     .primerCheckoutSession(session) { state in
>       if case .success(let result) = state {
>         print("Payment succeeded: \(result.paymentId)")
>       }
>     }
>   }
> }
> ```
>
> ### Key differences
>
> | Aspect | Drop-In | CheckoutComponents (UIKit) | CheckoutComponents (SwiftUI) |
> | - | - | - | - |
> | Entry point | `Primer.shared.showUniversalCheckout()` | `PrimerCheckoutPresenter.presentCheckout()` | `PrimerCheckout()` view (managed) or `PrimerCheckoutSession` + `.primerCheckoutSession` (custom) |
> | Configuration | `Primer.shared.configure(settings:delegate:)` | Parameters on `presentCheckout()` | Parameters on initializer |
> | Delegate | `PrimerDelegate` | `PrimerCheckoutPresenterDelegate` | `onCompletion` closure |
> | Payment handling | `.auto` or `.manual` | `.auto` only | `.auto` only |
> | Min iOS | 13.0 (v2.x) | 15.0 | 15.0 |
> | UI framework | UIKit | UIKit (wraps SwiftUI internally) | SwiftUI |
> | Dismiss | `Primer.shared.dismiss()` | `PrimerCheckoutPresenter.dismiss()` | View lifecycle |
>
> > [!WARNING]
> >
> > Primer Checkout creates the payment itself. If your Drop-In or Headless integration uses `PrimerSettings(paymentHandling: .manual)` and creates payments from your backend, switch to `.auto`. A checkout started with `.manual` fails immediately with a `PrimerError` whose `errorId` is `invalid-value`. Use `onBeforePaymentCreate` on [`PrimerCheckoutSession`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session) to pass an idempotency key or to abort a payment before the SDK creates it. Backend-driven manual approval is available on Web today and is coming to iOS, see [Migrate the manual flow](https://primer.io/docs/checkout/primer-checkout/migration/manual-flow.md).

***

## Accessing the SDK

> [tab] Web
>
> Some SDK methods require access to the `PrimerJS` instance. This instance is provided via the `primer:ready` event, which fires when the checkout is fully initialized.
>
> ### Getting the instance
>
> ```javascript theme={"dark"}
> const checkout = document.querySelector('primer-checkout');
>
> checkout.addEventListener('primer:ready', (event) => {
>   const primer = event.detail;
>
>   // Now you can call SDK methods
>   await primer.refreshSession();
> });
> ```
>
> ### Storing for later use
>
> If you need to call SDK methods outside of the event handler (e.g., in response to user actions or after a session expires), store the reference:
>
> ```javascript theme={"dark"}
> let primer;
>
> checkout.addEventListener('primer:ready', (event) => {
>   primer = event.detail;
> });
>
> // Later, when needed
> async function handleSessionRefresh() {
>   if (primer) {
>     await primer.refreshSession();
>   }
> }
> ```
>
> ### Available methods
>
> The `PrimerJS` instance provides:
>
> | Method | Description |
> | - | - |
> | `refreshSession()` | Sync the SDK with server-side session changes. Returns `Promise<void>`. |
> | `getPaymentMethods()` | Returns the list of available payment methods. |
> | `setCardholderName(name)` | Programmatically set the cardholder name field. |
> | `vault.startPayment(id, options?)` | Start payment with a vaulted method. |
> | `vault.createCvvInput(options)` | Create a CVV input for re-capture. |
> | `vault.delete(id)` | Delete a vaulted payment method. |
>
> > [!NOTE]
> >
> > Most integrations don't need to access the `PrimerJS` instance directly. The primary use cases are session refresh, programmatic cardholder name updates, and headless vault implementations.

> [tab] Android
>
> In Primer Checkout Components, checkout state is available via `PrimerCheckoutController.state`, a `StateFlow` you can collect in your composables:
>
> ```kotlin theme={"dark"}
> val checkout = rememberPrimerCheckoutController(clientToken, PrimerSettings())
> val state by checkout.state.collectAsStateWithLifecycle()
>
> when (state) {
>     is PrimerCheckoutState.Loading -> CircularProgressIndicator()
>     is PrimerCheckoutState.Ready -> { /* Render checkout */ }
>     is PrimerCheckoutState.Success -> { /* Payment completed */ }
>     is PrimerCheckoutState.Failure -> { /* Show error */ }
>     else -> Unit
> }
> ```
>
> The controller also provides:
>
> * `checkout.refresh()` — reinitialize the checkout session
> * `checkout.dismiss()` — programmatically dismiss the checkout UI
>
> See the [PrimerCheckoutController reference](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-controller.md) for the full API.

> [tab] iOS
>
> In Primer Checkout Components, outcomes are delivered as `PrimerCheckoutState` values in your `onCompletion` closure. `.failure` arrives once for each failed payment attempt, and the checkout stays usable for a retry. Then `.success` or `.dismissed` arrives exactly once. A failed initialization is the exception. It arrives once as `.failure` and ends a custom-layout session. The composable views own their interactive state through observable sessions.
>
> For custom layouts, hold a `PrimerCheckoutSession` and attach the `.primerCheckoutSession(_:theme:onCompletion:)` modifier. The composable views (`PrimerCardForm`, `PrimerPaymentMethods`) resolve their per-feature session — `PrimerCardFormSession`, `PrimerSelectionSession` — from the environment, and you observe those sessions inside your slot closures:
>
> ```swift theme={"dark"}
> @StateObject private var session: PrimerCheckoutSession
>
> init(clientToken: String) {
>   _session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken))
> }
>
> var body: some View {
>   ScrollView {
>     // Observe the card form session inside its slots
>     PrimerCardForm(
>       submitButton: { cardForm in
>         Button("Pay") { cardForm.submit() }
>           .disabled(!cardForm.state.isValid || cardForm.state.isLoading)
>       }
>     )
>   }
>   .primerCheckoutSession(session) { state in
>     // .failure for each failed attempt, then .success or .dismissed once
>   }
> }
> ```
>
> `PrimerCheckoutSession` also provides:
>
> * `await session.refresh()` — re-fetch configuration and payment methods after a client-session update
> * `session.cancel()` — tear the session down (the modifier wires this to `onDisappear`)
>
> A failed refresh reports `.failure` through `onCompletion`, and the session stays usable.
>
> See the [PrimerCheckoutSession reference](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session.md) for the full session API.

***

## Result handling

> [tab] Web
>
> The new SDK uses DOM events as the primary API for payment lifecycle handling. This replaces the callback-based approach from the legacy SDK.
>
> ### Migration table
>
> | Legacy callback | New event | Notes |
> | - | - | - |
> | `onCheckoutComplete(data)` | `primer:payment-success` | Unified `payment` property |
> | `onCheckoutFail(error, data, handler)` | `primer:payment-failure` | Simplified — no handler |
> | `onBeforePaymentCreate(data, handler)` | `primer:payment-start` | Use `preventDefault()` + handlers in `event.detail` |
> | `onPaymentCreationStart()` | `primer:payment-start` | Same event, check `paymentMethodType` |
> | `onClientSessionUpdate(session)` | Call `primer.refreshSession()` | Now a method, not event |
> | `submitButton.onDisable(disabled)` | `primer:state-change` | Check `isProcessing` / `isLoading` |
>
> ### Payment success
>
> **Legacy:**
>
> ```javascript theme={"dark"}
> onCheckoutComplete: (data) => {
>   console.log('Payment ID:', data.payment.id);
>   console.log('Customer email:', data.customer.email);
> }
> ```
>
> **New:**
>
> ```javascript theme={"dark"}
> checkout.addEventListener('primer:payment-success', (event) => {
>   const { payment, paymentMethodType, timestamp } = event.detail;
>
>   // payment is a PaymentSummary — contains partial card data
>   console.log('Last 4:', payment.last4Digits);
>   console.log('Network:', payment.network);
>   console.log('Method:', paymentMethodType);
>
>   window.location.href = '/confirmation';
> });
> ```
>
> The `PaymentSummary` contains partial card data (last 4 digits, network, cardholder name) but not full PII like customer email or full card number. Use server-side webhooks for complete payment data.
>
> ### Payment failure
>
> **Legacy:**
>
> ```javascript theme={"dark"}
> onCheckoutFail: (error, data, handler) => {
>   handler.showErrorMessage('Your card was declined');
> }
> ```
>
> **New:**
>
> ```javascript theme={"dark"}
> checkout.addEventListener('primer:payment-failure', (event) => {
>   const { error, payment, paymentMethodType, timestamp } = event.detail;
>
>   // error has: code, message, diagnosticsId, data
>   console.error('Failed:', error.message, error.diagnosticsId);
>
>   // The SDK shows the error in the UI automatically.
>   // Use this event for logging, analytics, or retry logic.
> });
> ```
>
> ### Pre-payment validation (intercepting payments)
>
> The legacy `onBeforePaymentCreate` callback is replaced by the `primer:payment-start` event. Use `event.preventDefault()` to intercept the payment flow, then call `continuePaymentCreation()` or `abortPaymentCreation()` from `event.detail`.
>
> **Legacy:**
>
> ```javascript theme={"dark"}
> onBeforePaymentCreate: (data, handler) => {
>   if (!termsAccepted) {
>     handler.showErrorMessage('Please accept the terms');
>     handler.abort();
>   } else {
>     handler.continuePaymentCreation();
>   }
> }
> ```
>
> **New:**
>
> ```javascript theme={"dark"}
> checkout.addEventListener('primer:payment-start', (event) => {
>   const { paymentMethodType, continuePaymentCreation, abortPaymentCreation } = event.detail;
>
>   // Prevent automatic continuation
>   event.preventDefault();
>
>   if (!document.getElementById('terms-checkbox').checked) {
>     // Show your own error UI
>     alert('Please accept the terms');
>     abortPaymentCreation();
>   } else {
>     // Optionally pass an idempotency key
>     continuePaymentCreation({ idempotencyKey: 'your-key' });
>   }
> });
> ```
>
> > [!NOTE]
> >
> > If you don't call `event.preventDefault()`, the payment continues automatically. Only use `preventDefault()` when you need to run validation or async checks before payment creation.
>
> ### Session refresh
>
> This is no longer a callback. It's a method you call when you need to sync the SDK with server-side session changes.
>
> **Legacy:**
>
> ```javascript theme={"dark"}
> onClientSessionUpdate: (session) => {
>   console.log('Session updated:', session);
> }
>
> checkout.updateClientSession(newClientToken);
> ```
>
> **New:**
>
> ```javascript theme={"dark"}
> // Update the client token, then call refreshSession()
> checkout.setAttribute('client-token', newClientToken);
>
> checkout.addEventListener('primer:ready', (event) => {
>   const primer = event.detail;
>   await primer.refreshSession();
> });
> ```

> [tab] Android
>
> The callback-based `PrimerCheckoutListener` is replaced by `StateFlow<PrimerCheckoutState>`. Payment outcomes are terminal cases of that single state flow — observe `checkout.state` and match on `PrimerCheckoutState.Success` / `.Failure`.
>
> ### Callback migration
>
> | Legacy (`PrimerCheckoutListener`) | New (CheckoutComponents) |
> | - | - |
> | `onCheckoutCompleted(checkoutData)` | `PrimerCheckoutState.Success` (via `checkout.state`) |
> | `onFailed(error, checkoutData, errorHandler)` | `PrimerCheckoutState.Failure` (via `checkout.state`) |
> | Listener callbacks | `StateFlow<PrimerCheckoutState>` |
>
> ```kotlin theme={"dark"}
> val state by checkout.state.collectAsStateWithLifecycle()
>
> LaunchedEffect(state) {
>     when (val s = state) {
>         is PrimerCheckoutState.Success -> {
>             val paymentId = s.checkoutData.payment.id
>             navigateToConfirmation(paymentId)
>         }
>         is PrimerCheckoutState.Failure -> {
>             Log.e("Checkout", "Error: ${s.primerError.description}")
>         }
>         else -> Unit
>     }
> }
>
> PrimerCheckoutSheet(
>     checkout = checkout,
>     onDismiss = {
>         navigateBack()
>     },
> )
> ```
>
> ### State observation
>
> ```kotlin theme={"dark"}
> val state by checkout.state.collectAsStateWithLifecycle()
>
> when (state) {
>     is PrimerCheckoutState.Loading -> { /* Show loading */ }
>     is PrimerCheckoutState.Ready -> { /* Checkout is interactive */ }
>     is PrimerCheckoutState.Success -> { /* Payment completed */ }
>     is PrimerCheckoutState.Failure -> { /* Handle error */ }
>     else -> Unit
> }
> ```
>
> See [PrimerCheckoutState](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-state.md) for the complete state reference, including the `Success` and `Failure` outcome cases.

> [tab] iOS
>
> The callback-based `PrimerDelegate` is replaced by either `PrimerCheckoutPresenterDelegate` (UIKit) or `onCompletion` + state observation (SwiftUI).
>
> ### UIKit — PrimerCheckoutPresenterDelegate
>
> | Legacy (`PrimerDelegate`) | New (`PrimerCheckoutPresenterDelegate`) |
> | - | - |
> | `primerDidCompleteCheckoutWithData(_:)` | `primerCheckoutPresenterDidCompleteWithSuccess(_ result: PaymentResult)` |
> | `primerDidFailWithError(_:data:decisionHandler:)` | `primerCheckoutPresenterDidFailWithError(_ error: PrimerError, checkoutData: PrimerCheckoutData?)` |
> | `primerDidDismiss()` | `primerCheckoutPresenterDidDismiss()` |
>
> `primerCheckoutPresenterDidFailWithError(_:checkoutData:)` fires once for each failed payment attempt, including a failed initialization.
>
> * While the SDK error screen is shown, the sheet stays open so the shopper can retry or, when available, choose another payment method. Don't dismiss the sheet or navigate away in this callback.
> * If the shopper closes the sheet on the error screen, the delegate receives `primerCheckoutPresenterDidDismiss()`. A successful retry reaches `primerCheckoutPresenterDidCompleteWithSuccess(_:)`.
> * When `uiOptions.isErrorScreenEnabled` is `false`, the sheet closes on failure.
> * When the payment exists, `checkoutData?.payment` carries the payment ID and order ID. When the failure happens before a payment exists, for example during initialization, `checkoutData` is `nil`.
>
> ```swift theme={"dark"}
> class CheckoutViewController: UIViewController, PrimerCheckoutPresenterDelegate {
>
>   func showCheckout() {
>     PrimerCheckoutPresenter.shared.delegate = self
>     PrimerCheckoutPresenter.presentCheckout(
>       clientToken: clientToken,
>       from: self,
>       primerSettings: PrimerSettings(paymentHandling: .auto)
>     )
>   }
>
>   func primerCheckoutPresenterDidCompleteWithSuccess(_ result: PaymentResult) {
>     let confirmationVC = ConfirmationViewController(paymentId: result.paymentId)
>     navigationController?.pushViewController(confirmationVC, animated: true)
>   }
>
>   func primerCheckoutPresenterDidFailWithError(_ error: PrimerError, checkoutData: PrimerCheckoutData?) {
>     print("Payment failed: \(error.errorId)")
>   }
>
>   func primerCheckoutPresenterDidDismiss() {
>     print("Checkout dismissed")
>   }
> }
> ```
>
> A failed 3DS challenge reaches `primerCheckoutPresenterDidFailWithError(_:checkoutData:)` like any other failed payment.
>
> ### SwiftUI — onCompletion + state observation
>
> | Legacy (`PrimerDelegate`) | New (CheckoutComponents) |
> | - | - |
> | `primerDidCompleteCheckoutWithData(_:)` | `onCompletion: .success(PaymentResult)` |
> | `primerDidFailWithError(_:data:decisionHandler:)` | `onCompletion: .failure(PrimerError, checkoutData: PrimerCheckoutData?)` |
> | `primerDidDismiss()` | `onCompletion: .dismissed` |
> | `primerWillCreatePaymentWithData(_:decisionHandler:)` | `PrimerCheckoutSession.onBeforePaymentCreate` handler |
>
> The managed `PrimerCheckout` view reports outcomes through `onCompletion`. It delivers `.failure` once for each failed attempt, including a failed initialization, while the checkout stays open for a retry. Then it delivers `.success` or `.dismissed` exactly once. Switch over the case to route your UI:
>
> ```swift theme={"dark"}
> PrimerCheckout(
>   clientToken: clientToken,
>   onCompletion: { state in
>     switch state {
>     case .success(let result):
>       navigateToConfirmation(paymentId: result.paymentId)
>     case let .failure(error, _):
>       logError(error)
>     case .dismissed:
>       navigateBack()
>     default:
>       break
>     }
>   }
> )
> ```
>
> For custom layouts, the `.primerCheckoutSession(_:theme:onCompletion:)` modifier delivers `.failure` once for each failed payment attempt, then `.success` or `.dismissed` exactly once. A failed initialization is delivered once as `.failure` and ends the session. To observe interactive state during the flow, read the `@Published state` on the per-feature session injected into each slot — for example `PrimerCardFormSession.state` inside a `PrimerCardForm` slot.
>
> > [!NOTE]
> >
> > `PaymentResult` replaces `PrimerCheckoutData` as the success type. It contains `paymentId`, `status`, `token`, `redirectUrl`, `errorMessage`, `amount`, `currencyCode`, and `paymentMethodType`. A failure carries `PrimerCheckoutData` as `checkoutData`.
>
> See [PrimerCheckoutState](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state.md) for the complete state lifecycle documentation.

***

## Configuration and theming

> [tab] Web
>
> ### Options changes
>
> #### Options that work the same
>
> These options are identical in both SDKs:
>
> * `locale` — Language/region code (e.g., `'en-GB'`). Falls back to `en-GB` if the locale is not supported.
> * `merchantDomain` — Your website domain (used for Apple Pay validation).
> * `submitButton.useBuiltInButton` — Show or hide the built-in submit button.
> * `submitButton.amountVisible` — Display the payment amount on the button.
>
> #### Options that changed
>
> | Legacy option | New option | Notes |
> | - | - | - |
> | `container` | Removed | The `<primer-checkout>` element *is* the container |
> | `vault.visible` | `vault.enabled` | Renamed — same purpose |
> | `form.inputLabelsVisible` | Removed | Labels are always visible for accessibility |
> | `successScreen.visible` | Removed | Handle success UI in your application |
> | `successScreen.message` | Removed | Use `primer:payment-success` event or the `checkout-complete` slot |
> | `apiVersion` | `sdkCore` | `sdkCore` defaults to `true`; set to `false` only for legacy compatibility |
>
> #### New options
>
> | Option | Description |
> | - | - |
> | `vault.headless` | Hide default vault UI for custom implementations |
> | `vault.showEmptyState` | Show a message when no saved payment methods exist |
>
> #### Success screen replacement
>
> The legacy SDK had a built-in success screen. The new SDK gives you two options:
>
> **Option 1: Use the `checkout-complete` slot** (declarative)
>
> ```html theme={"dark"}
> <primer-checkout client-token="your-client-token">
>   <primer-main slot="main">
>     <div slot="checkout-complete">
>       <h2>Thank you for your order!</h2>
>       <p>Your payment was processed successfully.</p>
>     </div>
>   </primer-main>
> </primer-checkout>
> ```
>
> **Option 2: Handle via event** (imperative)
>
> ```javascript theme={"dark"}
> checkout.addEventListener('primer:payment-success', (event) => {
>   const { payment } = event.detail;
>   window.location.href = `/confirmation?order=${payment.orderId}`;
> });
> ```
>
> ### Styling changes
>
> The styling system has fundamentally changed from JavaScript objects to CSS custom properties.
>
> #### Legacy: CSS-in-JS objects
>
> ```javascript theme={"dark"}
> PrimerSDK.showUniversalCheckout(clientToken, {
>   style: {
>     input: {
>       base: { fontSize: 16, color: '#000000' },
>       invalid: { color: '#DC3545', borderColor: '#DC3545' }
>     },
>     button: {
>       base: { backgroundColor: '#007BFF', color: '#FFFFFF' }
>     }
>   }
> });
> ```
>
> #### New: CSS custom properties
>
> **Option 1: CSS stylesheet** (recommended)
>
> ```css theme={"dark"}
> primer-checkout {
>   --primer-color-brand: #007BFF;
>   --primer-color-text-primary: #333333;
>   --primer-typography-brand: 'Inter', sans-serif;
>   --primer-radius-medium: 8px;
> }
> ```
>
> **Option 2: `custom-styles` attribute** (JSON string with camelCase keys)
>
> ```javascript theme={"dark"}
> checkout.setAttribute('custom-styles', JSON.stringify({
>   primerColorBrand: '#007BFF',
>   primerColorTextPrimary: '#333333',
>   primerTypographyBrand: 'Inter, sans-serif',
>   primerRadiusMedium: '8px',
> }));
> ```
>
> The conversion rule for the `custom-styles` attribute: `--primer-color-brand` → `primerColorBrand` (remove `--`, convert kebab-case to camelCase).
>
> **Option 3: Inline CSS**
>
> ```javascript theme={"dark"}
> checkout.style.setProperty('--primer-color-brand', '#007BFF');
> ```
>
> #### Key differences
>
> | Aspect | Legacy | New |
> | - | - | - |
> | Location | JavaScript options object | CSS stylesheet, `custom-styles` attribute, or inline styles |
> | Syntax | Nested JS objects | CSS variables with `--primer-` prefix |
> | Theming | Manual recreation for dark mode | Built-in dark theme via `class="primer-dark-theme"` |
> | Debugging | Difficult to inspect | Live editing in browser DevTools |
> | Performance | Runtime style injection | Native CSS cascade |
>
> #### Design token categories
>
> The new SDK organizes styling into token categories:
>
> * **Colors** — `--primer-color-brand`, `--primer-color-text-primary`, `--primer-color-background-primary`, and more
>
> * **Typography** — `--primer-typography-brand` (font family), `--primer-typography-body-medium-size`, etc.
>
> * **Spacing** — `--primer-space-xxsmall` (2px) through `--primer-space-xlarge` (20px)
>
> * **Border radius** — `--primer-radius-xsmall` (2px) through `--primer-radius-large` (12px)
>
> * **Sizing** — `--primer-size-small` through `--primer-size-xxxlarge`
>
> * **Animation** — `--primer-animation-duration`, `--primer-animation-easing`
>
> * **Border width** — `--primer-width-default` and state variants
>
> > See the [Styling Variables Reference](https://primer.io/docs/sdk/primer-checkout-web/styling-variables.md) for the complete list of available tokens.
>
> > [!NOTE]
> >
> > **Don't attempt a 1:1 mapping** from your legacy CSS-in-JS styles. Instead, start with your brand color (`--primer-color-brand`) and font (`--primer-typography-brand`), then use browser DevTools to inspect which tokens affect which elements and adjust from there.

> [tab] Android
>
> ### Settings
>
> `PrimerSettings` is now passed to `rememberPrimerCheckoutController()` instead of `Primer.instance.configure()`.
>
> | Legacy | New | Notes |
> | - | - | - |
> | `Primer.instance.configure(settings)` | `PrimerSettings` param on controller | Same pattern |
> | Theme via `PrimerSettings.uiOptions` | `PrimerTheme` data class on composables | Separate from settings |
> | Screen flags | Composable slot parameters | Custom screens via composition |
>
> ### Theming
>
> The legacy theme settings are replaced by `PrimerTheme`, a data class with design token overrides:
>
> ```kotlin theme={"dark"}
> PrimerCheckoutSheet(
>     checkout = checkout,
>     theme = PrimerTheme(
>         lightColorTokens = object : LightColorTokens() {
>             override val primerColorBrand: Color = Color(0xFF007BFF)
>         },
>         radiusTokens = RadiusTokens(
>             medium = 12.dp,
>         ),
>     ),
> )
> ```
>
> Token categories: `LightColorTokens`/`DarkColorTokens`, `RadiusTokens`, `SpacingTokens`, `TypographyTokens`, `WidthTokens`, and `SizeTokens`.
>
> ### Layout customization
>
> In Drop-In, customization was limited to settings. In CheckoutComponents, every screen is a composable slot you can override:
>
> ```kotlin theme={"dark"}
> PrimerCheckoutSheet(
>     checkout = checkout,
>     splash = { MyCustomSplash() },
>     loading = { MyCustomLoading() },
>     success = { data -> MyCustomSuccess(data) },
>     error = { primerError, _ -> MyCustomError(primerError) },
> )
> ```
>
> ### Payment method configuration
>
> Payment method behavior is configured server-side through the [client session API](https://primer.io/docs/checkout/client-session.md). No client-side payment method option migration is needed.
>
> See [SDK Options](https://primer.io/docs/checkout/primer-checkout/configuration/sdk-options.md), [Styling customization](https://primer.io/docs/checkout/primer-checkout/build-your-ui/styling-customization.md), and [Layout customization](https://primer.io/docs/checkout/primer-checkout/build-your-ui/layout-customization.md) for details.

> [tab] iOS
>
> ### Settings
>
> `PrimerSettings` is now passed directly to `PrimerCheckout` or `PrimerCheckoutPresenter.presentCheckout()` instead of `Primer.shared.configure()`.
>
> | Legacy | New | Notes |
> | - | - | - |
> | `Primer.shared.configure(settings:)` | `PrimerSettings` param on view/presenter/session | Same type, different delivery |
> | `PrimerTheme` (UIKit color/font overrides) | `PrimerCheckoutTheme` (design tokens) | New token-based system |
> | `PrimerUIOptions` screen flags | `PrimerUIOptions` in `PrimerSettings` | Scope varies by option, see below. For layout, use `@ViewBuilder` slots |
>
> `isSuccessScreenEnabled`, `isErrorScreenEnabled` and `appearanceMode` apply to `PrimerCheckout`, `PrimerCheckoutPresenter` and the composable views. `isInitScreenEnabled` applies only to `PrimerCheckout` and `PrimerCheckoutPresenter`, because the composable views never show an init screen. `dismissalMechanism` shows the close button on most SDK screens when it contains `.closeButton`. The BLIK, MB Way and country picker screens always show one. Only the `PrimerCheckoutPresenter` sheet enforces swipe-to-dismiss (`.gestures`).
>
> ### Theming
>
> The legacy `PrimerTheme` (colors and fonts) is replaced by `PrimerCheckoutTheme`, which uses a design token system with optional override structs. Every override is optional — set only the tokens you want to change and the SDK fills in the rest:
>
> ```swift theme={"dark"}
> PrimerCheckoutTheme(
>   colors: ColorOverrides(
>     primerColorBrand: Color(red: 0.91, green: 0.51, blue: 0.47),
>     primerColorTextPrimary: Color(white: 0.1)
>   ),
>   radius: RadiusOverrides(
>     primerRadiusMedium: 12
>   ),
>   typography: TypographyOverrides(
>     bodyMedium: TypographyOverrides.TypographyStyle(
>       font: "Inter",
>       weight: .semibold,
>       size: 16,
>       lineHeight: 20
>     )
>   )
> )
> ```
>
> Token categories: colors, radius, spacing, sizes, typography, and border widths. `colors` themes light mode only. To theme dark mode, also pass `darkColors`. Any color you leave out of `darkColors` uses Primer's dark default, not your `colors` value.
>
> ### Layout customization
>
> In Drop-In, customization was limited to theme properties. The managed `PrimerCheckout` view renders Primer's default screens. For full control, compose the views under `PrimerCheckoutSession` and customize them through their `@ViewBuilder` slots — for example the `PrimerCardForm` slots (`cardDetails`, `billingAddress`, `submitButton`), each of which receives a `PrimerCardFormSession`:
>
> ```swift theme={"dark"}
> ScrollView {
>   PrimerCardForm(
>     submitButton: { cardForm in
>       Button("Pay now") { cardForm.submit() }
>         .disabled(!cardForm.state.isValid || cardForm.state.isLoading)
>     }
>   )
>   PrimerPaymentMethods()
> }
> .primerCheckoutSession(session) { state in
>   // Receives .failure per failed attempt, then .success or .dismissed once.
> }
> ```
>
> Use `CardFormDefaults` (and `PaymentMethodsDefaults`) building blocks inside slots when you want Primer's prebuilt field views with a custom arrangement. Which fields appear is Dashboard-driven via `CardFormConfiguration`; visual styling is theme-driven through `PrimerCheckoutTheme` design tokens.
>
> ### Payment method configuration
>
> Payment method behavior (Apple Pay, Klarna, etc.) is configured server-side through the [client session API](https://primer.io/docs/checkout/client-session.md). No client-side payment method option migration is needed.
>
> See [SDK Options](https://primer.io/docs/checkout/primer-checkout/configuration/sdk-options.md), [Styling customization](https://primer.io/docs/checkout/primer-checkout/build-your-ui/styling-customization.md), and [Layout customization](https://primer.io/docs/checkout/primer-checkout/build-your-ui/layout-customization.md) for details.

***

## Web-specific details

> [!NOTE]
>
> The following sections apply to **Web only**. iOS and Android handle these concerns through the composable views and slots described above.

### Payment method options

#### Card form

The card form options have been simplified. The legacy `requireCvv` and `requireCardholderName` top-level options no longer exist. Use the `cardholderName` sub-object instead.

**Legacy:**

```javascript theme={"dark"}
card: {
  requireCvv: true,
  requireCardholderName: true,
}
```

**New:**

```javascript theme={"dark"}
card: {
  cardholderName: {
    required: true,
    visible: true,
    defaultValue: 'John Doe', // Optional: pre-fill the field
  }
}
```

CVV is always required by the card form — there is no option to disable it.

For runtime updates to the cardholder name after initialization, use `primer.setCardholderName('name')` instead of updating the options.

#### Apple Pay

The Apple Pay options have been restructured. The legacy `merchantId` and `merchantName` are handled server-side, not in client options.

**Legacy:**

```javascript theme={"dark"}
applePay: {
  merchantId: 'merchant.com.example',
  merchantName: 'Example Store',
  captureBillingAddress: true,
}
```

**New:**

```javascript theme={"dark"}
applePay: {
  buttonOptions: {
    type: 'buy',
    buttonStyle: 'black',
  },
  billingOptions: {
    requiredBillingContactFields: ['postalAddress'],
  },
  shippingOptions: {
    requiredShippingContactFields: ['postalAddress', 'name', 'phone', 'email'],
    requireShippingMethod: true,
  },
}
```

Key changes:

* `merchantId` and `merchantName` — removed from client options (configured server-side).
* `captureBillingAddress` — deprecated. Use `billingOptions.requiredBillingContactFields` instead. The only supported billing field is `'postalAddress'`.
* **Shipping fields** — use `'phone'` (not `'phoneNumber'`) and `'email'` (not `'emailAddress'`).
* **Button styling** — new `buttonOptions` object with `type` and `buttonStyle`.

> See the [Apple Pay reference](https://primer.io/docs/sdk/primer-checkout-web/apple-pay.md) for all button types and complete configuration.

#### Google Pay

Google Pay options are largely the same with some additions.

**Legacy:**

```javascript theme={"dark"}
googlePay: {
  merchantId: 'merchant-id-from-google',
  merchantName: 'Example Store',
  buttonType: 'buy',
  buttonColor: 'black',
}
```

**New:**

```javascript theme={"dark"}
googlePay: {
  buttonType: 'buy',
  buttonColor: 'black',
  buttonSizeMode: 'fill',
  buttonRadius: 4,
  buttonLocale: 'en',
  captureBillingAddress: true,
  captureShippingAddress: true,
  emailRequired: true,
  requireShippingMethod: true,
  shippingAddressParameters: {
    allowedCountryCodes: ['US', 'CA'],
    phoneNumberRequired: true,
  },
}
```

Key changes:

* `merchantId` and `merchantName` — removed from client options (configured server-side).
* New button options: `buttonSizeMode`, `buttonRadius`, `buttonLocale`.
* New address capture: `captureBillingAddress`, `captureShippingAddress`, `emailRequired`.
* New shipping: `requireShippingMethod`, `shippingAddressParameters`.

> See the [Google Pay reference](https://primer.io/docs/sdk/primer-checkout-web/google-pay.md) for complete configuration.

#### Klarna

The Klarna configuration has changed significantly from the legacy SDK. There is no `klarna.locale` option — the Klarna locale is derived from the top-level `locale` option.

**Legacy:**

```javascript theme={"dark"}
klarna: {
  locale: 'en-GB',
}
```

**New:**

```javascript theme={"dark"}
klarna: {
  paymentFlow: 'DEFAULT',           // 'DEFAULT' or 'PREFER_VAULT'
  allowedPaymentCategories: ['pay_now', 'pay_later', 'pay_over_time'],
  recurringPaymentDescription: '...', // Required for recurring payments
  buttonOptions: {
    text: 'Pay with Klarna',
  },
}
```

Set the locale at the top level instead:

```javascript theme={"dark"}
checkout.options = {
  locale: 'en-GB',
  klarna: {
    paymentFlow: 'DEFAULT',
  },
};
```

> See the [SDK Options Reference](https://primer.io/docs/sdk/primer-checkout-web/sdk-options-reference.md#klarna-options) for all Klarna options.

#### PayPal

PayPal options have been restructured with a `style` sub-object.

**Legacy:**

```javascript theme={"dark"}
paypal: {
  buttonColor: 'blue',
  buttonShape: 'rect',
  buttonHeight: 45,
  buttonLabel: 'pay',
  buttonTagline: false,
  paymentFlow: 'PREFER_VAULT',
}
```

**New:**

```javascript theme={"dark"}
paypal: {
  style: {
    color: 'gold',    // Default is 'gold', not 'blue'
    shape: 'rect',
    height: 45,
    label: 'pay',
    tagline: false,
    layout: 'vertical',
    borderRadius: 4,
  },
  vault: true,  // Replaces paymentFlow: 'PREFER_VAULT'
}
```

Key changes:

* Button properties moved into `style` sub-object and shortened (`buttonColor` → `style.color`).
* Default color is `'gold'`, not `'blue'`.
* `buttonSize` removed — use `style.height` with a pixel value.
* `paymentFlow: 'PREFER_VAULT'` → `vault: true`.

> See the [PayPal reference](https://primer.io/docs/sdk/primer-checkout-web/paypal.md) for all options.

### Vault changes

#### Configuration

**Legacy:**

```javascript theme={"dark"}
vault: {
  visible: true,
  deletionDisabled: false,
}
```

**New:**

```javascript theme={"dark"}
vault: {
  enabled: true,       // Required — must be true to use vault
  headless: false,     // New: hide default UI for custom implementations
  showEmptyState: true, // New: show message when no saved methods
}
```

Key changes:

* `visible` → `enabled` (renamed, required).
* `deletionDisabled` — no longer a client-side option. Deletion capability is managed through the vault UI or programmatically via `primer.vault.delete()`.
* **New:** `headless` mode for building fully custom vault UIs.
* **New:** `showEmptyState` to show a message when the user has no saved methods.

#### Vault API methods

The new SDK provides programmatic vault management:

```javascript theme={"dark"}
checkout.addEventListener('primer:ready', async (event) => {
  const primer = event.detail;

  // Create CVV input for re-capture
  const cvvInput = await primer.vault.createCvvInput({
    cardNetwork: 'VISA',         // Card network, not paymentMethodId
    container: '#cvv-container',
    placeholder: 'CVV',
  });

  // Start payment with a saved method
  await primer.vault.startPayment(paymentMethodId);

  // Or with CVV for cards requiring re-capture
  await primer.vault.startPayment(paymentMethodId, { cvv: '123' });

  // Delete a saved method
  await primer.vault.delete(paymentMethodId);
});
```

> [!WARNING]
>
> `vault.createCvvInput()` takes a `cardNetwork` string (e.g., `'VISA'`, `'MASTERCARD'`), not a `paymentMethodId`. It returns a `Promise` — always use `await`.

#### Vault events

| Event | Payload | Description |
| - | - | - |
| `primer:vault-methods-update` | `{ vaultedPayments, cvvRecapture, timestamp }` | Vaulted methods loaded or changed |
| `primer:vault-selection-change` | `{ paymentMethodId, timestamp }` | User selected or deselected a saved method |
| `primer:vault-submit` | `{ source? }` | Dispatch this to trigger vault payment submission |

The `vaultedPayments` payload is an array of `VaultedPaymentMethodSummary` objects.

```javascript theme={"dark"}
checkout.addEventListener('primer:vault-methods-update', (event) => {
  const { vaultedPayments, cvvRecapture, timestamp } = event.detail;

  // vaultedPayments is already an array
  console.log(`${vaultedPayments.length} saved methods`);

  vaultedPayments.forEach(method => {
    console.log(method.paymentInstrumentData?.last4Digits);
  });

  if (cvvRecapture) {
    // CVV re-entry is required for vaulted card payments
  }
});
```

#### Headless vault mode

Build a completely custom vault UI while retaining full vault functionality:

```javascript theme={"dark"}
checkout.options = {
  vault: {
    enabled: true,
    headless: true,
  },
};

checkout.addEventListener('primer:vault-methods-update', async (event) => {
  const { vaultedPayments, cvvRecapture } = event.detail;

  // Render your custom vault UI
  vaultedPayments.forEach(method => {
    if (method.paymentInstrumentType === 'PAYMENT_CARD') {
      const { network, last4Digits } = method.paymentInstrumentData;
      // Render: Visa •••• 4242
    }
  });

  // Create CVV input if re-capture is required
  if (cvvRecapture) {
    const primer = /* get from primer:ready */;
    const cvvInput = await primer.vault.createCvvInput({
      cardNetwork: vaultedPayments[0].paymentInstrumentData.network,
      container: '#cvv-container',
    });
  }
});
```

> See the [Vault Manager component reference](https://primer.io/docs/sdk/primer-checkout-web/components/primer-vault-manager.md) for the complete headless vault implementation guide.

### Custom submit button

The mechanism for triggering payment submission from an external button has changed from an imperative method call to a dispatched event.

#### Card form submission

**Legacy:**

```javascript theme={"dark"}
document.getElementById('my-button').addEventListener('click', () => {
  checkout.submit();
});
```

**New:**

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

#### Vault payment submission

For vault payments, dispatch `primer:vault-submit` instead:

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

> [!NOTE]
>
> An alternative approach for card form submission: you can call `cardForm.submit()` directly on the `<primer-card-form>` element if you have a reference to it.
>
> ```javascript theme={"dark"}
> const cardForm = document.querySelector('primer-card-form');
> await cardForm.submit();
> ```

#### Button state management

Monitor the `primer:state-change` event to enable/disable your custom button:

```javascript theme={"dark"}
checkout.addEventListener('primer:state-change', (event) => {
  const { isProcessing, isLoading } = event.detail;
  document.getElementById('my-button').disabled = isProcessing || isLoading;
});
```

For card form validation state, listen to `primer:card-success` and `primer:card-error`:

```javascript theme={"dark"}
checkout.addEventListener('primer:card-success', () => {
  // Card form is valid — enable submit
});

checkout.addEventListener('primer:card-error', (event) => {
  // Card form has validation errors
  console.log('Errors:', event.detail.errors);
});
```

> See the [External Submit Button recipe](https://primer.io/docs/checkout/primer-checkout/guides-and-recipes/external-submit-button.md) for a complete example.

### Events

The new SDK uses a comprehensive DOM event system as the primary API for payment lifecycle handling.

#### Complete event reference

##### Lifecycle events

| Event | Payload | Description |
| - | - | - |
| `primer:ready` | `PrimerJS` instance | SDK initialized and ready |
| `primer:state-change` | `{ isProcessing, isLoading, isSuccessful, primerJsError, paymentFailure }` | State changes during payment lifecycle |
| `primer:methods-update` | `InitializedPaymentMethod[]` | Available payment methods loaded or changed |

##### Payment events

| Event | Payload | Description |
| - | - | - |
| `primer:payment-start` | `{ paymentMethodType, abortPaymentCreation, continuePaymentCreation, timestamp }` | Payment creation begins — use `preventDefault()` to intercept |
| `primer:payment-success` | `{ payment, paymentMethodType, timestamp }` | Payment completed successfully |
| `primer:payment-failure` | `{ error, payment?, paymentMethodType, timestamp }` | Payment failed |

##### Card form events

| Event | Payload | Description |
| - | - | - |
| `primer:card-network-change` | `CardNetworksContextType` | Card network detected from user input |
| `primer:card-success` | `{ result }` | Card form validation succeeded |
| `primer:card-error` | `{ errors }` | Card form has validation errors |

##### Vault events

| Event | Payload | Description |
| - | - | - |
| `primer:vault-methods-update` | `{ vaultedPayments, cvvRecapture, timestamp }` | Vaulted methods loaded or changed |
| `primer:vault-selection-change` | `{ paymentMethodId, timestamp }` | User selected a saved method |
| `primer:show-other-payments-toggle` | `{ action?, source? }` | Dispatch to toggle the "other payments" section |
| `primer:show-other-payments-toggled` | `{ expanded }` | "Other payments" toggle state changed |

##### Triggerable events

You can dispatch these to control the SDK. Both require `bubbles: true` and `composed: true`.

| Event | Purpose |
| - | - |
| `primer:card-submit` | Trigger card form submission |
| `primer:vault-submit` | Trigger vault payment submission |
| `primer:show-other-payments-toggle` | Toggle the "other payment methods" section |

> See the [Events Reference](https://primer.io/docs/sdk/primer-checkout-web/events-reference.md) for full payload types and usage examples.

***

## Migration checklist

> [tab] Web
>
> Verify your migration is complete:
>
> * **Replaced package** — Uninstalled `@primer-io/checkout-web`, installed `@primer-io/primer-js`
> * **Updated imports** — Using `loadPrimer` and new type names
> * **Updated initialization** — Using `<primer-checkout>` element with `setAttribute('client-token', ...)` and `.options = {}`
> * **Updated to events** — Using `primer:payment-success`, `primer:payment-failure` events
> * **Updated pre-payment validation** — Using `primer:payment-start` with `preventDefault()`
> * **Updated styling** — Replaced CSS-in-JS objects with `--primer-*` CSS custom properties
> * **Updated payment method config** — Corrected Apple Pay, PayPal, Klarna options
> * **Updated vault config** — Using `vault.enabled` and vault events
> * **Updated custom submit** — Dispatching `primer:card-submit` / `primer:vault-submit` events
> * **Tested in your framework** — See [Framework Migration Guide](https://primer.io/docs/checkout/primer-checkout/migration/frameworks.md)

> [tab] Android
>
> Verify your migration is complete:
>
> * **Added Jetpack Compose** — `buildFeatures { compose = true }` in build config (if not already present)
> * **Replaced entry point** — Using `rememberPrimerCheckoutController()` + `PrimerCheckoutSheet()` instead of `Primer.instance.showUniversalCheckout()`
> * **Replaced listener** — Observing `PrimerCheckoutState.Success` / `.Failure` on `checkout.state` instead of `PrimerCheckoutListener`
> * **Updated theming** — Using `PrimerTheme` data class instead of `PrimerSettings.uiOptions` (if customizing)
> * **Tested state observation** — Collecting `StateFlow<PrimerCheckoutState>` with `collectAsStateWithLifecycle()`
> * **No server-side changes** — Backend integration remains the same
> * **No dependency changes** — Same SDK BOM

> [tab] iOS
>
> Verify your migration is complete:
>
> * **Updated minimum deployment target** — iOS 15.0
>
> * **UIKit**: Replaced `Primer.shared.showUniversalCheckout()` with `PrimerCheckoutPresenter.presentCheckout()`
>
> * **UIKit**: Replaced `PrimerDelegate` with `PrimerCheckoutPresenterDelegate`
>
> * **SwiftUI**: Using `PrimerCheckout` view with `onCompletion` callback
>
> * **Updated result handling** — Using `PaymentResult` instead of `PrimerCheckoutData` for success
>
> * **Payment handling** — `PrimerSettings` uses the default `.auto`. `.manual` is not supported and fails the checkout at start
>
> * **Updated theming** — Using `PrimerCheckoutTheme` instead of `PrimerTheme` (if customizing)
>
> * **Tested 3DS flows** — A failed 3DS challenge arrives as a normal failure (`primerCheckoutPresenterDidFailWithError(_:checkoutData:)` or `onCompletion: .failure`)
>
> * **No server-side changes** — Backend integration remains the same
>
> * **Updated the dependency** — `PrimerSDK` pinned to `3.0.0-beta.8`
>
> > [accordion] Known behavior changes from v2.x
> >
> > These changes may affect existing integrations that also use v2.x Drop-In or Headless:
> >
> > * **`PrimerLocaleData` region code**: When providing only `languageCode` (e.g., `PrimerLocaleData(languageCode: "de")`), the `regionCode` now defaults to `nil` instead of the device region. This produces locale code `"de"` instead of `"de-US"`. To preserve v2.x behavior, explicitly pass `regionCode`.
> > * **Mastercard number formatting**: Card number display changed from groups of 4-6-6 (`5555 550000 4444`) to standard 4-4-4-4 (`5555 5500 0044 44`), matching industry standard and other platforms.
> > * **`PrimerPaymentMethodType` is now public**: All payment method type cases are part of the public API. Merchants can use these for conditional logic (e.g., `if type == .paymentCard`).

***

## Next steps

> [tab] Web
>
> > [card] [Framework Migration](https://primer.io/docs/checkout/primer-checkout/migration/frameworks.md)
> >
> > React, Vue, Svelte, and Angular patterns
>
> > [card] [SDK Options Reference](https://primer.io/docs/sdk/primer-checkout-web/sdk-options-reference.md)
> >
> > Complete configuration reference
>
> > [card] [Events Reference](https://primer.io/docs/sdk/primer-checkout-web/events-reference.md)
> >
> > Full event payloads and types
>
> > [card] [Styling Variables](https://primer.io/docs/sdk/primer-checkout-web/styling-variables.md)
> >
> > Complete design token reference

> [tab] Android
>
> > [card] [PrimerCheckoutSheet](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-sheet.md)
> >
> > Modal checkout composable
>
> > [card] [PrimerCheckoutController](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-controller.md)
> >
> > Session controller API
>
> > [card] [PrimerCheckoutState](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-state.md)
> >
> > Checkout state and payment outcomes
>
> > [card] [Theming](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/theming/primer-theme.md)
> >
> > Design token system

> [tab] iOS
>
> > [card] [PrimerCheckout](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout.md)
> >
> > SwiftUI view API reference
>
> > [card] [UIKit Integration](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/guides/uikit-integration.md)
> >
> > PrimerCheckoutPresenter guide
>
> > [card] [PrimerCheckoutState](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state.md)
> >
> > Checkout state lifecycle
>
> > [card] [API Overview](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/overview.md)
> >
> > Composable views and sessions


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