v6 Migration Guide for Humans

#This guide covers upgrading @payrails/web-sdk from 5.x to 6.x: every breaking
change, before/after code for each, and how to verify the result. It assumes a
working 5.x integration. If an AI coding agent performs the migration for you,
point it at the agent runbook, which routes through
this guide.

Upgrade checklist

Work through these in order — later steps assume the earlier ones are done:

  1. Update the package: npm install @payrails/web-sdk@6.
  2. Add await to every Payrails.init(...) call (it now returns a Promise)
    and make the surrounding code async — see
    §1.
  3. Remove the manual stylesheet import
    (import '@payrails/web-sdk/payrails-styles.css') — it no longer resolves;
    styles load automatically — see §1.
  4. If your site sets a Content-Security-Policy, allow assets.payrails.io in
    script-src and style-src.
  5. Remove any Payrails.preloadCardForm() calls; the method is gone — see
    §2.
  6. Replace every events: {} callback bag with the typed .on() API — the bags
    are gone in v6; the one exception is onClientInitialized at Payrails.init
    — see §4.
  7. Update onClientInitialized handlers: the argument is now the plain
    execution response, and its helper methods moved to the payrails instance —
    see §3.
  8. Replace wallet availability callbacks (onGooglePayAvailable,
    onApplePayAvailable, onPaypalAvailable) with await button.isAvailable
    see §5.
  9. Migrate styles options to appearance on every Payrails-drawn element —
    see §6. Provider-drawn wallet buttons keep
    their styles chrome options.
  10. Update TypeScript type imports — see §7.
  11. Re-test your checkout visually: the components ship a refreshed design — see
    §8.

1. Payrails.init is asynchronous

In v6 the npm package is a thin loader. Payrails.init(...) returns a Promise
of the SDK instance: at init the SDK loads its full bundle and stylesheet from
the Payrails CDN, pinned to the SDK version configured for your merchant account
(with fallback to the latest release of the current major). Every merchant runs
an SDK version compatible with their account configuration, without waiting for
you to update the npm dependency.

// Before (v5)
import { Payrails } from '@payrails/web-sdk';
import '@payrails/web-sdk/payrails-styles.css';

const payrails = Payrails.init(clientInitResponse, options);

// After (v6)
import { Payrails } from '@payrails/web-sdk';

const payrails = await Payrails.init(clientInitResponse, options);

Notes:

  • Stylesheet — the SDK injects its stylesheet together with the bundle.
    Remove the manual payrails-styles.css import: the file is no longer in the
    package and the import fails to resolve (the package exports map has no CSS
    subpath), so a leftover import is a build error, not a silent no-op.
  • Content-Security-Policy — the bundle and stylesheet load from
    assets.payrails.io; allow it in script-src and style-src.
  • Failure mode — if the bundle cannot load (blocked CDN, offline, timeout),
    init rejects with a PayrailsError (PAYRAILS_INIT_FAILED) after 15
    seconds. Handle the rejection where you handle other init errors.
  • Async propagation — callers of your init code may need to become async
    too. In React, initialize inside an effect and store the instance in state; in
    Vue, initialize in an async mounted/onMounted hook.
  • Browser onlyinit needs a DOM to load the bundle; call it in the
    browser, not during server-side rendering. In SSR frameworks, run it in a
    client-only lifecycle hook (or behind a typeof window !== 'undefined'
    guard).

2. Payrails.preloadCardForm() is removed

The static Payrails.preloadCardForm() no longer exists — the npm package is
now a loader, so before init there is nothing to preload from. Delete the
call; the SDK bundle itself is fetched at init, and the secure card fields
load when the card form mounts. There is no v6 equivalent for warming the card
form ahead of time — if you used preloadCardForm for perceived performance,
mount the card form earlier (hidden if necessary) instead:

// Before (v5)
Payrails.preloadCardForm();
const payrails = Payrails.init(clientInitResponse, options);

// After (v6)
const payrails = await Payrails.init(clientInitResponse, options);

3. onClientInitialized receives the execution response object

onClientInitialized remains a callback passed at init — it is the one event
with no .on() form, because it fires during initialization, before a listener
could be registered. It also fires again after each session refresh (see
sessionExpired below).

Two things changed about it:

The argument is now the plain workflow execution response
(WorkflowExecutionResponse, e.g. execution.id) instead of the execution
class instance.

The class instance's helper methods moved to the payrails instance. If
your handler called helpers on the argument, call them on the SDK instance
instead:

v5 — on the callback argumentv6 — on the payrails instance
execution.savedCreditCardspayrails.getSavedCreditCards()
execution.getPaymentInstallmentOptions(m)payrails.getPaymentInstallmentOptions(m)
execution.getPaymentMethodConfig(m)payrails.getPaymentMethodConfig(m)
// Before (v5) — class instance with helper methods
Payrails.init(clientInitResponse, {
  events: {
    onClientInitialized: (execution) => {
      const cards = execution.savedCreditCards;
    },
  },
});

// After (v6) — plain response object; helpers live on the instance
const payrails = await Payrails.init(clientInitResponse, {
  events: {
    onClientInitialized: (execution) => {
      console.log('SDK ready', execution.id);
    },
  },
});
const cards = payrails.getSavedCreditCards();

Read anything else you used to pull off the class directly from the response
object.

4. Typed .on() event API replaces events: {} callback bags

v6 removes the legacy events: {} callback bags from every element factory and
from payrails.dropin() — passing one is a compile error, with no back-compat
bridge. Subscribe with the typed .on(name, handler) API instead. .on()
supports multiple listeners per event and returns an unsubscribe function:

const off = payrails.on('success', (event) => {
  console.log(event.action, event.paymentMethodCode);
});
// later: off();

Events live at two levels:

  • Instancepayrails.on(...) for session and payment-attempt events.
    Payment-attempt payloads carry executionId, paymentMethodCode, and
    action ('AUTHORIZE' | 'TOKENIZE').
  • Elementelement.on(...) for events about one element (a card form, a
    button, the drop-in), where element is the object returned by
    payrails.cardForm(), payrails.paymentButton(), and so on.

Behavior differences from the legacy callbacks

  1. Canceling uses event.preventDefault(), not a boolean return. Callbacks
    that returned Promise<boolean> to cancel a flow are now cancelable events.

    // Before (v5)
    paymentButton({
      events: { onPaymentButtonClicked: async () => await isReady() },
    });
    
    // After (v6)
    payrails.on('buttonClicked', async (event) => {
      if (!(await isReady())) event.preventDefault();
    });
  2. Payment-attempt events are per-session, not per-element. In v5 each
    element had its own bag: applePayButton({ events: { onSuccess } }) fired
    only for that button. In v6 a single payrails.on('success', ...) fires for
    any element's success in the session — card form, wallet buttons,
    drop-in, all of them. Filter with event.paymentMethodCode or event.action
    if you need per-method behavior:

    payrails.on('success', (event) => {
      if (event.paymentMethodCode === 'card') showCardReceipt();
      if (event.paymentMethodCode === 'applePay') showWalletReceipt();
    });
  3. A thrown handler no longer blocks the payment. In v5, a gate callback
    (onRequestStart, onPaymentButtonClicked) that threw propagated as a
    payment failure. In v6 the SDK catches and logs the error and the flow
    continues. Blocking validation or fraud checks must call
    event.preventDefault() explicitly:

    payrails.on('buttonClicked', async (event) => {
      try {
        await runFraudCheck();
      } catch {
        event.preventDefault(); // required — throwing alone won't block
      }
    });
  4. sessionExpired handlers run serially. The SDK awaits each handler in
    registration order; a slow handler delays the session refresh. Do only
    refresh-adjacent work there.

sessionExpired can refresh the session

A handler may return fresh init options ({ version, data }); the SDK re-
initializes from the first non-null result and re-runs onClientInitialized:

payrails.on('sessionExpired', async () => fetchNewInitResponse());

Instance events

Legacy callbackReplacement
events.onSessionExpired / onPaymentSessionExpiredpayrails.on('sessionExpired', async () => newInitOptions)
events.onSuccess / onAuthorizeSuccesspayrails.on('success', (e) => ...)
events.onFailed / onAuthorizeFailedpayrails.on('failed', (e) => console.log(e.data?.code))
events.onPending / onAuthorizePendingpayrails.on('pending', (e) => ...)
events.onRequestStart / onAuthorizeRequestStartpayrails.on('requestStart', (e) => { if (!ok) e.preventDefault(); })
events.onButtonClicked / onPaymentButtonClickedpayrails.on('buttonClicked', (e) => { if (!ok) e.preventDefault(); })
events.onThreeDSecureChallengepayrails.on('actionRequired', (e) => ...)
events.onDeliveryAddressChangedpayrails.on('deliveryAddressChanged', (e) => ...)

Payload notes: failed carries the failure essentials in e.data
({ code?, message? }) rather than at the top level; buttonClicked adds
bin? for card payments; deliveryAddressChanged replaces the v5
resolve-false-to-reject contract with preventDefault().

Element events

Legacy callback (per element)Replacement
onChange(e)element.on('change', (e) => e.isValid)
onFocus()element.on('focus', () => ...)
-element.on('blur', () => ...)
onReady()element.on('ready', () => ...)
onSaveInstrumentCheckboxChanged({ checked })element.on('saveInstrumentCheckboxChanged', (e) => e.checked)
onPreferredSchemeChanged(...)element.on('preferredSchemeChanged', (e) => ...)
onBillingAddressChanged(...)element.on('billingAddressChanged', (e) => ...)
onValidate(...)element.on('validate', (e) => ...)
onValidationChange(isValid) (dynamic element)element.on('validate', (e) => e.isValid)
onStateChanged(state)element.on('stateChanged', (e) => e.state)
onPaymentOptionSelected(e)element.on('paymentOptionSelected', (e) => ...)
onGooglePayAvailable / onApplePayAvailable / onPaypalAvailableawait button.isAvailable — a promise, not an event; see §5

Full payload types and cancelation semantics:
Events reference.

5. Wallet availability is a promise, not an event

Wallet availability is an environment check that settles once — modelling it as
an event meant subscribers could race the check. In v6 every express payment
button (googlePayButton, applePayButton, paypalButton) exposes
readonly isAvailable: Promise<boolean> instead:

// Before (v5)
const button = payrails.paypalButton({
  events: { onPaypalAvailable: () => button.mount('#paypal-slot') },
});

// After (v6)
const button = payrails.paypalButton({});
if (await button.isAvailable) {
  button.mount('#paypal-slot');
} else {
  showFallback();
}

The promise never rejects — it resolves to false on any check-side failure.
The instance-level checks payrails.isGooglePayAvailable(merchantName?) and
payrails.isApplePayAvailable() also remain available.

6. styles becomes appearance

v6 replaces the per-component structured styles object with a single
appearance option on every element the SDK draws itself. appearance.rules is
plain CSS-like key/value: selectors on the outside, CSS declarations on the
inside. Selectors target stable class names the SDK guarantees on the DOM; state
variants live on BEM modifier classes.

// Before (v5)
payrails.paymentButton({
  styles: {
    base: { backgroundColor: '#1a1a1a', color: '#fff' },
    hover: { backgroundColor: '#333' },
    disabled: { opacity: '0.5' },
    loading: { cursor: 'wait' },
  },
});

// After (v6)
payrails.paymentButton({
  appearance: {
    rules: {
      '.payrails-button': { backgroundColor: '#1a1a1a', color: '#fff' },
      '.payrails-button:hover': { backgroundColor: '#333' },
      '.payrails-button--disabled': { opacity: '0.5' },
      '.payrails-button--loading': { cursor: 'wait' },
    },
  },
});

Unlike the closed vocabulary of v5 styles keys, rules accepts any selector a
browser supports — :focus-visible, ::placeholder, ::selection, @media,
@supports.

Which options changed

Element factoryv5v6
payrails.cardFormstyles (structured)appearance (CardFormAppearance, with nested child slots)
payrails.paymentButtonstyles (base/hover/disabled/loading)appearance
payrails.dropinstyles (per-component keys)appearance (DropinAppearance, keyed by building block)
payrails.cardListappearance
payrails.dynamicElementstylesappearance
payrails.genericRedirectButtonstylesappearance; for Revolut Pay, revolutOptions (see below)
payrails.leanButtonstyles.button / styles.dialogappearance / dialogCustomization (see below)
container.createCollectElementinputStyles/labelStyles/errorTextStylesappearance — the legacy fields still type-check but are ignored
payrails.collectContainerstyles— (style the fields via each element's appearance)
payrails.googlePayButtonstyles (provider chrome)unchanged — provider chrome stays on styles
payrails.applePayButtonstyles (provider chrome)unchanged
payrails.paypalButtonstyles (provider chrome)unchanged

Collect elements: inputStyles, labelStyles, and errorTextStyles
still exist on createCollectElement's options type but have no effect at
runtime in v6 — migrate them to appearance or your field styling silently
disappears.

The class names on the DOM

Target these generic classes from appearance.rules: .payrails-input,
.payrails-button, .payrails-dropdown, .payrails-label, .payrails-tile,
.payrails-container, .payrails-row, .payrails-cell, .payrails-icon,
.payrails-checkbox, .payrails-error, .payrails-text.

State variants use BEM modifiers:

StateClass
Field with invalid input.payrails-input--invalid
Field with valid input.payrails-input--valid
Field is empty.payrails-input--empty
Field touched since load.payrails-input--dirty
Button in loading state.payrails-button--loading
Button disabled.payrails-button--disabled
Checkbox checked.payrails-checkbox--checked
Brand tile selected.payrails-tile--selected

Native pseudo-classes (:hover, :focus, :focus-visible, :disabled,
:autofill, ::placeholder, ::selection) work anywhere they are valid.

The --invalid and --valid classes clear while a field is focused, so a field
being corrected does not show the error state. For a persistent invalid look on
touched fields, key off --dirty:

.payrails-input--dirty:not(:focus).payrails-input--invalid {
  border-color: #dc2626;
}

Card form: field-by-field mapping

v5 keyv6 selector under appearance.rules
styles.wrapper.payrails-container
styles.base(removed — set on .payrails-input)
styles.inputFields.all.base.payrails-input
styles.inputFields.all.focus.payrails-input:focus
styles.inputFields.all.complete.payrails-input--valid
styles.inputFields.all.invalid.payrails-input--invalid
styles.inputFields.all.empty.payrails-input--empty
styles.inputFields.all.cardIcon.payrails-icon (inside card-number field)
styles.labels.all.payrails-label
styles.errorTextStyles.base.payrails-error
styles.storeInstrumentCheckbox (or its deprecated alias storeCardCheckbox).payrails-checkbox

Per-field-type keys (styles.inputFields.CARD_NUMBER.*) have no direct
equivalent — in practice the generic .payrails-input covers most needs.

Nested widgets — the installments dropdown, address selector, and brand selector
— each have their own slot on CardFormAppearance:

payrails.cardForm({
  appearance: {
    rules: {
      '.payrails-container': { gap: '16px' },
      '.payrails-input': { border: '1px solid #eae8ee' },
    },
    installments: {
      rules: { '.payrails-dropdown': { borderRadius: '8px' } },
    },
    address: {
      rules: { '.payrails-dropdown, .payrails-input': { borderRadius: '8px' } },
    },
    brandSelector: {
      rules: { '.payrails-tile--selected': { borderColor: '#4F46E5' } },
    },
  },
});

The card payment button is a sibling of the card form, not a child: pass its
appearance to payrails.paymentButton({ appearance }) (standalone) or to
DropinAppearance.cardPaymentButton (drop-in mode).

Collect elements: field-by-field mapping

v5 keyv6 selector
inputStyles.base.payrails-input
inputStyles.focus.payrails-input:focus
inputStyles.complete.payrails-input--valid
inputStyles.empty.payrails-input--empty
inputStyles.invalid.payrails-input--invalid
inputStyles.cardIcon.payrails-icon
labelStyles.base.payrails-label
labelStyles.focus.payrails-field--focused .payrails-label
errorTextStyles.base.payrails-error

Only the root { rules } is forwarded to each collect element's iframe; nested
widget keys are ignored (secure fields have no sub-widgets).

Drop-in: appearance keyed by building block

DropinAppearance mirrors the drop-in's structure — root rules paint the
container; each building block takes its own appearance under a matching key:

payrails.dropin({
  appearance: {
    rules: {
      '.payrails-container': { borderRadius: '12px' },
    },
    cardForm: {
      rules: { '.payrails-input': { border: '1px solid #ddd' } },
      installments: {
        rules: { '.payrails-dropdown': { borderRadius: '8px' } },
      },
    },
    cardPaymentButton: {
      rules: { '.payrails-button': { backgroundColor: '#4F46E5' } },
    },
    loadingScreen: { rules: {} },
    authSuccess: { rules: {} },
    authFailed: { rules: {} },
  },
});

The full slot list and per-slot class contract:
Appearance reference.

Provider-drawn buttons keep styles — but the drop-in path moved

Google Pay, Apple Pay, and PayPal buttons are drawn by the provider's SDK, so
CSS cannot reach them. Their chrome options (buttonColor/buttonType for
Google Pay, type/style for Apple Pay, color/shape for PayPal) stay on
the standalone element's styles option, unchanged from v5.

In drop-in mode the nesting moved: dropin.styles.googlePayButton (and
equivalents) is gone; pass the same object under
paymentMethodsConfiguration.<method>.styles instead:

// Before (v5)
payrails.dropin({
  styles: {
    googlePayButton: { buttonColor: 'black', buttonType: 'buy' },
  },
});

// After (v6)
payrails.dropin({
  paymentMethodsConfiguration: {
    googlePay: {
      styles: { buttonColor: 'black', buttonType: 'buy' },
    },
  },
});

Revolut Pay: stylesrevolutOptions

The Revolut Pay button reads Revolut's own branded config
({ theme, width, borderRadius }), not CSS. The field is renamed accordingly —
standalone on genericRedirectButton and in the drop-in:

// Before (v5)
payrails.genericRedirectButton({
  paymentMethod: { paymentMethodCode: 'revolutPay' },
  styles: { theme: 'dark', width: '100%' },
});

// After (v6)
payrails.genericRedirectButton({
  paymentMethod: { paymentMethodCode: 'revolutPay' },
  revolutOptions: { theme: 'dark', width: '100%' },
});

// Drop-in: paymentMethodsConfiguration.revolutPay.revolutOptions

Non-Revolut redirect buttons style via appearance.rules on
.payrails-generic-button like any other button.

Lean button: styles.buttonappearance, styles.dialogdialogCustomization

The Lean-hosted bank dialog reads Lean SDK config (theme colors, border radius)
appearance.rules cannot reach into Lean's iframe, so that part moved to its
own option:

// Before (v5)
payrails.leanButton({
  styles: {
    button: { base: { backgroundColor: '#1a1a1a' } },
    dialog: { themeColor: '#1a1a1a', overlayColor: 'rgba(0,0,0,0.5)' },
  },
});

// After (v6)
payrails.leanButton({
  appearance: {
    rules: { '.payrails-button': { backgroundColor: '#1a1a1a' } },
  },
  dialogCustomization: {
    themeColor: '#1a1a1a',
    overlayColor: 'rgba(0,0,0,0.5)',
  },
});

Cascade behavior

SDK defaults live in the CSS layer @layer payrails-defaults; your appearance
rules land in @layer payrails-appearance, which always wins over the defaults.
Rules in your own external stylesheets are unlayered and beat both — if you
previously targeted .payrails-* classes from your own CSS, that keeps working,
but internal markup changed in the redesign (§8),
so re-test every override and prefer moving it into appearance.rules.

translations and fonts did not change shape.

7. TypeScript changes

  • PayrailsContainerType is removed along with the containerType option
    of CollectContainerOptions — secure-fields containers no longer come in two
    flavors. CollectContainerOptions now has containerId and fonts only. The
    container returned by payrails.collectContainer() keeps its exported
    FramesContainer type, unchanged from v5.
  • New appearance types are exported: Appearance, AppearanceRules,
    AppearanceDeclarations, CardFormAppearance, DropinAppearance.
  • RevolutPayStyles remains exported — it is now the type of
    revolutOptions.
  • Event types are exported for the .on() surface: PayrailsEvents,
    PayrailsEventName, PayrailsEventHandler, ElementEvents,
    ElementEventName, ElementEventHandler, PaymentAttemptContext,
    ActionRequiredEvent, and the per-event payload types.

These affect type annotations only; runtime behavior is covered by the sections
above.

8. Refreshed visual design

The drop-in, card form, payment buttons, and result screens ship a refreshed
design: a unified button style across payment methods, updated typography and
spacing, and improved responsiveness in narrow containers.

No code changes are required, but the rendered DOM and default styles have
changed:

  • If you customized the checkout in v5, re-apply your intent through
    appearance (§6) and re-test each customized
    component.
  • If you override SDK styles with your own CSS selectors, expect breakage —
    internal class names and markup have changed. Re-test and update your
    overrides. §6 documents the supported class
    names and state modifiers.

Verify the migration

After migrating, confirm each of these:

  1. It compiles. Run your type-check/build. Leftover v5 usage (events: bags
    on element options, the CSS import, preloadCardForm,
    PayrailsContainerType) fails the build.
  2. Init resolves. The page loads the SDK bundle and stylesheet from
    assets.payrails.io (visible in the network tab) and await Payrails.init
    resolves without PAYRAILS_INIT_FAILED. Test with your production CSP if you
    have one.
  3. Elements render styled. The card form / drop-in mounts with the v6 design
    and your appearance rules applied — a completely unstyled form usually
    means styling was left on dead v5 options.
  4. A test payment fires your listeners. Complete a payment in the TEST
    environment and confirm your success/failed handlers fire (with the right
    paymentMethodCode filters if you pay with multiple methods).
  5. Gates still gate. If you migrated onRequestStart/onButtonClicked
    logic, confirm a rejected check actually blocks the payment — remember that
    throwing no longer blocks
    (§4).
  6. Session refresh works if you use sessionExpired: let a session expire
    (or force it) and confirm the SDK re-initializes and onClientInitialized
    fires again.


Did this page help you?