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:
- Update the package:
npm install @payrails/web-sdk@6. - Add
awaitto everyPayrails.init(...)call (it now returns aPromise)
and make the surrounding code async — see
§1. - Remove the manual stylesheet import
(import '@payrails/web-sdk/payrails-styles.css') — it no longer resolves;
styles load automatically — see §1. - If your site sets a Content-Security-Policy, allow
assets.payrails.ioin
script-srcandstyle-src. - Remove any
Payrails.preloadCardForm()calls; the method is gone — see
§2. - Replace every
events: {}callback bag with the typed.on()API — the bags
are gone in v6; the one exception isonClientInitializedatPayrails.init
— see §4. - Update
onClientInitializedhandlers: the argument is now the plain
execution response, and its helper methods moved to thepayrailsinstance —
see §3. - Replace wallet availability callbacks (
onGooglePayAvailable,
onApplePayAvailable,onPaypalAvailable) withawait button.isAvailable—
see §5. - Migrate
stylesoptions toappearanceon every Payrails-drawn element —
see §6. Provider-drawn wallet buttons keep
theirstyleschrome options. - Update TypeScript type imports — see §7.
- Re-test your checkout visually: the components ship a refreshed design — see
§8.
1. Payrails.init is asynchronous
Payrails.init is asynchronousIn 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 manualpayrails-styles.cssimport: the file is no longer in the
package and the import fails to resolve (the packageexportsmap 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 inscript-srcandstyle-src. - Failure mode — if the bundle cannot load (blocked CDN, offline, timeout),
initrejects with aPayrailsError(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 asyncmounted/onMountedhook. - Browser only —
initneeds 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 atypeof window !== 'undefined'
guard).
2. Payrails.preloadCardForm() is removed
Payrails.preloadCardForm() is removedThe 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 receives the execution response objectonClientInitialized 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 argument | v6 — on the payrails instance |
|---|---|
execution.savedCreditCards | payrails.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
.on() event API replaces events: {} callback bagsv6 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:
- Instance —
payrails.on(...)for session and payment-attempt events.
Payment-attempt payloads carryexecutionId,paymentMethodCode, and
action('AUTHORIZE'|'TOKENIZE'). - Element —
element.on(...)for events about one element (a card form, a
button, the drop-in), whereelementis the object returned by
payrails.cardForm(),payrails.paymentButton(), and so on.
Behavior differences from the legacy callbacks
-
Canceling uses
event.preventDefault(), not a boolean return. Callbacks
that returnedPromise<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(); }); -
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 singlepayrails.on('success', ...)fires for
any element's success in the session — card form, wallet buttons,
drop-in, all of them. Filter withevent.paymentMethodCodeorevent.action
if you need per-method behavior:payrails.on('success', (event) => { if (event.paymentMethodCode === 'card') showCardReceipt(); if (event.paymentMethodCode === 'applePay') showWalletReceipt(); }); -
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 } }); -
sessionExpiredhandlers 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
sessionExpired can refresh the sessionA 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 callback | Replacement |
|---|---|
events.onSessionExpired / onPaymentSessionExpired | payrails.on('sessionExpired', async () => newInitOptions) |
events.onSuccess / onAuthorizeSuccess | payrails.on('success', (e) => ...) |
events.onFailed / onAuthorizeFailed | payrails.on('failed', (e) => console.log(e.data?.code)) |
events.onPending / onAuthorizePending | payrails.on('pending', (e) => ...) |
events.onRequestStart / onAuthorizeRequestStart | payrails.on('requestStart', (e) => { if (!ok) e.preventDefault(); }) |
events.onButtonClicked / onPaymentButtonClicked | payrails.on('buttonClicked', (e) => { if (!ok) e.preventDefault(); }) |
events.onThreeDSecureChallenge | payrails.on('actionRequired', (e) => ...) |
events.onDeliveryAddressChanged | payrails.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 / onPaypalAvailable | await 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
styles becomes appearancev6 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 factory | v5 | v6 |
|---|---|---|
payrails.cardForm | styles (structured) | appearance (CardFormAppearance, with nested child slots) |
payrails.paymentButton | styles (base/hover/disabled/loading) | appearance |
payrails.dropin | styles (per-component keys) | appearance (DropinAppearance, keyed by building block) |
payrails.cardList | — | appearance |
payrails.dynamicElement | styles | appearance |
payrails.genericRedirectButton | styles | appearance; for Revolut Pay, revolutOptions (see below) |
payrails.leanButton | styles.button / styles.dialog | appearance / dialogCustomization (see below) |
container.createCollectElement | inputStyles/labelStyles/errorTextStyles | appearance — the legacy fields still type-check but are ignored |
payrails.collectContainer | styles | — (style the fields via each element's appearance) |
payrails.googlePayButton | styles (provider chrome) | unchanged — provider chrome stays on styles |
payrails.applePayButton | styles (provider chrome) | unchanged |
payrails.paypalButton | styles (provider chrome) | unchanged |
Collect elements:
inputStyles,labelStyles, anderrorTextStyles
still exist oncreateCollectElement's options type but have no effect at
runtime in v6 — migrate them toappearanceor 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:
| State | Class |
|---|---|
| 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 key | v6 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 key | v6 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
styles — but the drop-in path movedGoogle 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: styles → revolutOptions
styles → revolutOptionsThe 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.revolutOptionsNon-Revolut redirect buttons style via appearance.rules on
.payrails-generic-button like any other button.
Lean button: styles.button → appearance, styles.dialog → dialogCustomization
styles.button → appearance, styles.dialog → dialogCustomizationThe 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
PayrailsContainerTypeis removed along with thecontainerTypeoption
ofCollectContainerOptions— secure-fields containers no longer come in two
flavors.CollectContainerOptionsnow hascontainerIdandfontsonly. The
container returned bypayrails.collectContainer()keeps its exported
FramesContainertype, unchanged from v5.- New appearance types are exported:
Appearance,AppearanceRules,
AppearanceDeclarations,CardFormAppearance,DropinAppearance. RevolutPayStylesremains 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:
- It compiles. Run your type-check/build. Leftover v5 usage (
events:bags
on element options, the CSS import,preloadCardForm,
PayrailsContainerType) fails the build. - Init resolves. The page loads the SDK bundle and stylesheet from
assets.payrails.io(visible in the network tab) andawait Payrails.init
resolves withoutPAYRAILS_INIT_FAILED. Test with your production CSP if you
have one. - Elements render styled. The card form / drop-in mounts with the v6 design
and yourappearancerules applied — a completely unstyled form usually
means styling was left on dead v5 options. - A test payment fires your listeners. Complete a payment in the
TEST
environment and confirm yoursuccess/failedhandlers fire (with the right
paymentMethodCodefilters if you pay with multiple methods). - Gates still gate. If you migrated
onRequestStart/onButtonClicked
logic, confirm a rejected check actually blocks the payment — remember that
throwing no longer blocks
(§4). - Session refresh works if you use
sessionExpired: let a session expire
(or force it) and confirm the SDK re-initializes andonClientInitialized
fires again.
Updated 2 days ago