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.
Not every section will apply — skip sections for features you don’t use.
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.
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 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:
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)") } } }}
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
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 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.
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, 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:
@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 // .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 for the full session API.
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.primerError.description}") } else -> Unit }}PrimerCheckoutSheet( checkout = checkout, onDismiss = { navigateBack() },)
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.
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:
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.
PaymentResult replaces PrimerCheckoutData as the success type. It contains paymentId, status, token, redirectUrl, errorMessage, amount, currencyCode, and paymentMethodType. A failure carries PrimerCheckoutData as checkoutData.
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.
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).
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:
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.
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 // 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.
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 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
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).