Migrate from Drop-In or Headless to Primer Checkout Components.
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.
Not every section will apply — skip sections for features you don’t use.
Primer Checkout Components is part of the same PrimerSDK package. No dependency changes are needed — if you already have the SDK installed via CocoaPods or Swift Package Manager, you’re ready to go.
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()
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.
For UIKit apps, PrimerCheckoutPresenter provides the most direct migration path. It wraps the SwiftUI checkout in a UIHostingController and handles presentation.
// Set delegatePrimerCheckoutPresenter.shared.delegate = self// Present checkout — minimalPrimerCheckoutPresenter.presentCheckout(clientToken: clientToken, from: self)// Or with settingsPrimerCheckoutPresenter.presentCheckout( clientToken: clientToken, from: self, primerSettings: PrimerSettings(paymentHandling: .auto))// Or with settings and themePrimerCheckoutPresenter.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
For SwiftUI apps, use the managed PrimerCheckout view directly. It renders Primer’s default screens end to end and reports the outcome through onCompletion:
PrimerCheckout( clientToken: clientToken, primerSettings: PrimerSettings(paymentHandling: .auto), primerTheme: PrimerCheckoutTheme(), onCompletion: { state in switch state { case .success(let result): print("Payment succeeded: \(result.paymentId)") case .failure(let 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(_:onCompletion:) modifier:
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)") } } }}
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.
const checkout = document.querySelector('primer-checkout');checkout.addEventListener('primer:ready', (event) => { const primer = event.detail; // Now you can call SDK methods await primer.refreshSession();});
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:
let primer;checkout.addEventListener('primer:ready', (event) => { primer = event.detail;});// Later, when neededasync function handleSessionRefresh() { if (primer) { await primer.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.
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.
In Primer Checkout Components, checkout state is available via PrimerCheckoutController.state, a StateFlow you can collect in your composables:
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
In Primer Checkout Components, checkout state is delivered through the terminal PrimerCheckoutState in your onCompletion closure, and the composable views own their interactive state through observable sessions.For custom layouts, hold a PrimerCheckoutSession and attach the .primerCheckoutSession(_: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:
@StateObject private var session: PrimerCheckoutSessioninit(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 // Terminal PrimerCheckoutState: .success / .failure / .dismissed }}
PrimerCheckoutSession also provides:
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)
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.
onCheckoutFail: (error, data, handler) => { handler.showErrorMessage('Your card was declined');}
New:
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.});
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:
onBeforePaymentCreate: (data, handler) => { if (!termsAccepted) { handler.showErrorMessage('Please accept the terms'); handler.abort(); } else { handler.continuePaymentCreation(); }}
New:
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' }); }});
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.
// Update the client token, then call refreshSession()checkout.setAttribute('client-token', newClientToken);checkout.addEventListener('primer:ready', (event) => { const primer = event.detail; await primer.refreshSession();});
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.
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.error.description}") } else -> Unit }}PrimerCheckoutSheet( checkout = checkout, onDismiss = { navigateBack() },)
The managed PrimerCheckout view reports the terminal PrimerCheckoutState through onCompletion. Switch over the case to route your UI:
PrimerCheckout( clientToken: clientToken, onCompletion: { state in switch state { case .success(let result): navigateToConfirmation(paymentId: result.paymentId) case .failure(let error): logError(error) case .dismissed: navigateBack() default: // .initializing and .ready are lifecycle states and are // never delivered to onCompletion. break } })
For custom layouts, the same terminal state is delivered through the .primerCheckoutSession(_:onCompletion:) modifier. 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.
PaymentResult replaces CheckoutData as the success type. It contains paymentId, status, token, amount, currencyCode, and paymentMethodType.
The legacy SDK had a built-in success screen. The new SDK gives you two options:Option 1: Use the checkout-complete slot (declarative)
<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>
The conversion rule for the custom-styles attribute: --primer-color-brand → primerColorBrand (remove --, convert kebab-case to camelCase).Option 3: Inline CSS
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.
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:
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:
ScrollView { PrimerCardForm( submitButton: { cardForm in Button("Pay now") { cardForm.submit() } .disabled(!cardForm.state.isValid || cardForm.state.isLoading) } ) PrimerPaymentMethods()}.primerCheckoutSession(session) { state in // React to the terminal state.}
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.
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:
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.
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:
klarna: { locale: 'en-GB',}
New:
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', },}
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.
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.
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
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 CheckoutData
Updated theming — Using PrimerCheckoutTheme instead of PrimerTheme (if customizing)
Tested 3DS flows — Optional delegate callbacks available for 3DS lifecycle
No server-side changes — Backend integration remains the same
No package changes — Same PrimerSDK dependency
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).