Chat on WhatsApp
Checkout & Conversion 12 min read

Magento Checkout Step Customization — Splitting Payment Into Multi-Step

Surfacing BNPL options (Klarna, Affirm, Sezzle) in Magento checkout is not just a payment-method toggle — those gateways need the shipping country and cart total to decide eligibility, which means payment must become its own step that boots only after shipping is set. Here is the full Luma path (Knockout subscribe on setShippingInformation) and the Hyvä path (Magewire listener on shipping.updated), with server-side guards via Magento\Quote\Model\QuoteValidator and the gateway-eligibility cache layer that keeps the step responsive.

Magento Checkout Step Customization — Splitting Payment Into Multi-Step

Magento multi-step payment customization is the checkout architecture that defers payment-method rendering until after the shipping step has posted, so payment gateways receive the shipping country and cart total before they decide which methods to offer on a Magento 2.4.4 — 2.4.9 storefront. The fix is a custom step component on Luma and a Magewire-driven event listener on Hyvä — both pinned by a server-side validator that refuses any submit where payment is set before shipping. Here is the production code for both stacks, with the gateway-eligibility cache that keeps the UX responsive.

BNPL eligibility breaks the default checkout flow

Magento's default checkout renders payment in parallel with shipping on Luma, and on Hyvä the payment partial mounts right after the customer-information step. That works fine for Stripe, Braintree, and PayPal — those gateways accept any cart and decide later. It does not work for BNPL.

Klarna, Affirm, and Sezzle each return a different set of eligible products depending on shipping country and cart total. Klarna's /v1/payments/sessions endpoint requires purchase_country and order_amount. Affirm rejects sessions under $50 or over $30,000. Sezzle returns one of pay-in-4, pay-in-2, or not eligible per the cart total bucket.[1]

Render the BNPL widget before shipping is selected and you ship one of three bad outcomes: the widget renders with a default country and the customer sees a method they cannot use; the widget fails eligibility silently and shows nothing even when the cart would qualify with a different address; or the widget renders, the customer selects it, and the gateway rejects the order at submit.

A payment method that needs shipping context cannot render in parallel with shipping. The step has to wait. The Magento default does not.

Fix: split payment into its own step that boots only after shipping has POSTed to /V1/carts/mine/shipping-information. On Luma, a custom Knockout component subscribed to the shipping-step's success callback. On Hyvä, a Magewire listener on Hyvä's shipping.updated event. Both feed the same backend guard.

Architecture: four moving parts

  1. Frontend step trigger — Knockout subscribe (Luma) or Magewire $listeners (Hyvä). Detects that shipping is set.
  2. Eligibility resolver — server-side service that calls each enabled BNPL gateway with country + total and caches the result for 60 seconds.
  3. Payment partial swap — re-renders the payment method list with eligible BNPL methods inserted.
  4. Server-side guardQuoteValidator plugin that rejects any submit where payment is set before shipping or the cache is stale.

1. Luma path — Knockout custom step

Luma checkout is a chain of Knockout components registered via checkout_index_index.xml. Each has a navigator that controls visibility. We extend the payment step into a separately-gated component that does not mount until shipping is set.

The custom step component

// app/code/Vendor/MultiStepPayment/view/frontend/web/js/view/payment-step.js
define([
    'jquery',
    'ko',
    'underscore',
    'mage/storage',
    'Magento_Checkout/js/view/payment',
    'Magento_Checkout/js/model/quote',
    'Magento_Checkout/js/model/step-navigator',
    'Magento_Checkout/js/action/get-payment-information'
], function ($, ko, _, storage, PaymentView, quote, stepNavigator, getPaymentInformation) {
    'use strict';

    return PaymentView.extend({
        defaults: {
            template: 'Vendor_MultiStepPayment/payment-step',
            isVisible: ko.observable(false),
            isLoading: ko.observable(false),
            eligibleMethods: ko.observableArray([])
        },

        initialize: function () {
            this._super();
            this.registerStep();
            this.subscribeToShipping();
            return this;
        },

        registerStep: function () {
            stepNavigator.registerStep(
                'payment-step',
                null,
                $.mage.__('Payment'),
                this.isVisible,
                _.bind(this.navigate, this),
                30
            );
        },

        navigate: function () {
            this.isVisible(true);
        },

        subscribeToShipping: function () {
            var self = this;
            // Fires after Magento_Checkout/js/action/set-shipping-information completes.
            quote.shippingAddress.subscribe(function (addr) {
                if (!addr || !addr.countryId) {
                    return;
                }
                if (!quote.shippingMethod()) {
                    return;
                }
                self.refreshEligibleMethods(addr.countryId, quote.totals() && quote.totals().grand_total);
            });

            // Also recompute if cart total changes after shipping is set (coupon, qty edit).
            quote.totals.subscribe(function (totals) {
                if (!self.isVisible() || !totals) {
                    return;
                }
                var addr = quote.shippingAddress();
                if (addr && addr.countryId) {
                    self.refreshEligibleMethods(addr.countryId, totals.grand_total);
                }
            });
        },

        refreshEligibleMethods: function (countryId, grandTotal) {
            var self = this;
            self.isLoading(true);
            return storage.post(
                '/rest/V1/carts/mine/bnpl-eligibility',
                JSON.stringify({ country: countryId, total: grandTotal })
            ).done(function (methods) {
                self.eligibleMethods(methods);
                getPaymentInformation();   // re-fetch /payment-information for the partial
            }).always(function () {
                self.isLoading(false);
            });
        }
    });
});

The two subscribe calls are load-bearing — one watches quote.shippingAddress (changes when the customer posts the shipping form), one watches quote.totals (changes on coupon or qty edit). The component extends Magento_Checkout/js/view/payment, so it inherits standard payment-method rendering and only adds BNPL gating on top.[2]

Registering the step in layout XML

<!-- app/code/Vendor/MultiStepPayment/view/frontend/layout/checkout_index_index.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
  <body>
    <referenceBlock name="checkout.root">
      <arguments>
        <argument name="jsLayout" xsi:type="array">
          <item name="components" xsi:type="array">
            <item name="checkout" xsi:type="array">
              <item name="children" xsi:type="array">
                <item name="steps" xsi:type="array">
                  <item name="children" xsi:type="array">
                    <item name="payment-step" xsi:type="array">
                      <item name="component" xsi:type="string">Vendor_MultiStepPayment/js/view/payment-step</item>
                      <item name="displayArea" xsi:type="string">payment-step</item>
                      <item name="sortOrder" xsi:type="string">30</item>
                    </item>
                  </item>
                </item>
              </item>
            </item>
          </item>
        </argument>
      </arguments>
    </referenceBlock>
  </body>
</page>

The Knockout template

<!-- view/frontend/web/template/payment-step.html -->
<div id="payment-step" data-bind="visible: isVisible()" class="checkout-payment-method">
    <div data-bind="visible: isLoading()" class="loader">
        <span>Loading payment options for your region...</span>
    </div>
    <div data-bind="visible: !isLoading()">
        <div class="payment-methods" data-bind="foreach: getActivePaymentMethods()">
            <div class="payment-method" data-bind="attr: { 'data-method': method }">
                <!-- standard payment renderers mounted here by parent payment view -->
                <each args="data: $parent.getRegion(getCode())"><render/></each>
            </div>
        </div>
        <div data-bind="if: eligibleMethods().length === 0">
            <p>Buy-now-pay-later options will appear here once your shipping address is confirmed.</p>
        </div>
    </div>
</div>

The isVisible guard means the step does not show until the parent navigator advances. We register it at sortOrder=30, after shipping (10) — the navigator advances steps in order only after the previous step's navigate() callback resolves.

2. Hyvä path — Magewire event listener

Hyvä's checkout is a server-rendered Magewire flow. The shipping component dispatches a shipping.updated event when the customer saves their address and method. We listen for it in a dedicated payment component.[3]

The Magewire component

<?php
// app/code/Vendor/MultiStepPayment/Magewire/Checkout/PaymentStep.php
namespace Vendor\MultiStepPayment\Magewire\Checkout;

use Magento\Checkout\Model\Session as CheckoutSession;
use Magewirephp\Magewire\Component;
use Vendor\MultiStepPayment\Service\BnplEligibility;

class PaymentStep extends Component
{
    public bool $isReady = false;
    public array $eligibleMethods = [];
    public ?string $shippingCountry = null;
    public ?float $grandTotal = null;

    protected $listeners = [
        'shipping.updated' => 'onShippingUpdated',
        'cart.totals.updated' => 'onTotalsUpdated',
    ];

    public function __construct(
        private CheckoutSession $checkoutSession,
        private BnplEligibility $eligibility
    ) {}

    public function mount(): void
    {
        $quote = $this->checkoutSession->getQuote();
        if ($quote->getShippingAddress()->getCountryId()
            && $quote->getShippingAddress()->getShippingMethod()) {
            $this->onShippingUpdated();
        }
    }

    public function onShippingUpdated(): void
    {
        $quote = $this->checkoutSession->getQuote();
        $this->shippingCountry = $quote->getShippingAddress()->getCountryId();
        $this->grandTotal = (float)$quote->getGrandTotal();
        $this->eligibleMethods = $this->eligibility->resolve(
            $this->shippingCountry,
            $this->grandTotal
        );
        $this->isReady = true;
    }

    public function onTotalsUpdated(): void
    {
        if ($this->isReady) {
            $this->onShippingUpdated();
        }
    }
}

The Magewire template

<!-- view/frontend/templates/magewire/checkout/payment-step.phtml -->
<div wire:id="payment-step" class="checkout-payment-step mt-6">
    <template wire:loading.delay>
        <div class="text-sm text-gray-500">Refreshing payment options for <?= $block->escapeHtml($shippingCountry) ?>...</div>
    </template>

    <?php if (!$isReady): ?>
        <div class="rounded border border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
            Confirm your shipping address and method to see available payment options.
        </div>
    <?php else: ?>
        <h3 class="text-lg font-semibold mb-3">Choose a payment method</h3>
        <ul class="space-y-2">
            <?php foreach ($eligibleMethods as $method): ?>
                <li class="flex items-center gap-3 rounded border border-gray-200 p-3 hover:border-primary">
                    <input type="radio"
                           name="payment[method]"
                           value="<?= $block->escapeHtmlAttr($method['code']) ?>"
                           wire:model="selectedMethod" />
                    <span class="font-medium"><?= $block->escapeHtml($method['title']) ?></span>
                    <?php if (!empty($method['badge'])): ?>
                        <span class="ml-auto text-xs rounded bg-primary-soft px-2 py-1">
                            <?= $block->escapeHtml($method['badge']) ?>
                        </span>
                    <?php endif; ?>
                </li>
            <?php endforeach; ?>
        </ul>
    <?php endif; ?>
</div>

Registering with Hyvä Checkout

<!-- view/frontend/layout/hyva_checkout_components.xml -->
<components xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <component name="checkout.payment-step"
               template="Vendor_MultiStepPayment::magewire/checkout/payment-step.phtml"
               magewire="Vendor\MultiStepPayment\Magewire\Checkout\PaymentStep"
               sortOrder="50"
               parent="checkout.payment"/>
</components>

The Hyvä ShippingMethod component already dispatches shipping.updated when the customer hits "Next". Our listener picks it up and runs the eligibility resolver server-side, then Magewire re-renders the partial.

3. The BNPL eligibility resolver

Both stacks call into the same resolver. The resolver hits each enabled BNPL gateway with the country and total, parses the response, and returns a normalized list of eligible methods.

<?php
// app/code/Vendor/MultiStepPayment/Service/BnplEligibility.php
namespace Vendor\MultiStepPayment\Service;

use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\CacheInterface;
use Vendor\MultiStepPayment\Service\Gateway\KlarnaClient;
use Vendor\MultiStepPayment\Service\Gateway\AffirmClient;
use Vendor\MultiStepPayment\Service\Gateway\SezzleClient;

class BnplEligibility
{
    private const CACHE_TTL = 60;
    private const CACHE_TAG = 'BNPL_ELIG';

    public function __construct(
        private CacheInterface $cache,
        private KlarnaClient $klarna,
        private AffirmClient $affirm,
        private SezzleClient $sezzle
    ) {}

    public function resolve(string $country, float $total): array
    {
        $bucket = $this->bucket($total);
        $key = sprintf('bnpl_elig_%s_%d', $country, $bucket);

        $cached = $this->cache->load($key);
        if ($cached) {
            return json_decode($cached, true);
        }

        $methods = [];
        if ($result = $this->klarna->check($country, $total)) {
            $methods[] = $result;
        }
        if ($result = $this->affirm->check($country, $total)) {
            $methods[] = $result;
        }
        if ($result = $this->sezzle->check($country, $total)) {
            $methods[] = $result;
        }

        $this->cache->save(
            json_encode($methods),
            $key,
            [self::CACHE_TAG],
            self::CACHE_TTL
        );
        return $methods;
    }

    private function bucket(float $total): int
    {
        // 50-unit buckets so cache hits across small cart variations.
        return (int)floor($total / 50) * 50;
    }
}

The bucket key is intentional. If we cached by exact total, every coupon or qty change would miss. Bucketing by 50 currency units means small cart edits hit the same cache entry. Tradeoff: the decision can be slightly off when the total straddles a bucket boundary — we handle that with a re-check inside QuoteValidator at submit.

4. Server-side guard via QuoteValidator

The frontend guards (Knockout subscribe, Magewire listener) are UX-only. A determined attacker can call /V1/carts/mine/payment-information directly with curl and bypass them. The server-side gate is non-negotiable.

<?php
// app/code/Vendor/MultiStepPayment/Plugin/QuoteValidatorPlugin.php
namespace Vendor\MultiStepPayment\Plugin;

use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\QuoteValidator;
use Vendor\MultiStepPayment\Service\BnplEligibility;

class QuoteValidatorPlugin
{
    public function __construct(private BnplEligibility $eligibility) {}

    public function beforeValidateBeforeSubmit(QuoteValidator $subject, Quote $quote)
    {
        $shippingAddress = $quote->getShippingAddress();
        $payment = $quote->getPayment();

        if (!$shippingAddress->getCountryId() || !$shippingAddress->getShippingMethod()) {
            throw new LocalizedException(
                __('Shipping address and method must be set before selecting payment.')
            );
        }

        $bnplMethods = ['klarna_pay_later', 'affirm_gateway', 'sezzle'];
        if (in_array($payment->getMethod(), $bnplMethods, true)) {
            $eligible = $this->eligibility->resolve(
                $shippingAddress->getCountryId(),
                (float)$quote->getGrandTotal()
            );
            $eligibleCodes = array_column($eligible, 'code');
            if (!in_array($payment->getMethod(), $eligibleCodes, true)) {
                throw new LocalizedException(
                    __('The selected payment method is not eligible for this cart. Please choose another.')
                );
            }
        }

        return [$quote];
    }
}
<!-- etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Quote\Model\QuoteValidator">
        <plugin name="vendor_multistep_payment_quote_validator"
                type="Vendor\MultiStepPayment\Plugin\QuoteValidatorPlugin"
                sortOrder="10"/>
    </type>
</config>

This plugin runs inside QuoteValidator::validateBeforeSubmit, which Magento calls in Magento\Quote\Model\QuoteManagement::submit right before the order is placed.[4] By the time we get here, the payment method is finalized on the quote, so we have the last chance to reject a stale-cache BNPL selection.

Luma KO step injection vs Hyvä Magewire event

ConcernLuma (Knockout)Hyvä (Magewire)
Step triggerquote.shippingAddress.subscribe() + quote.totals.subscribe()$listeners = ['shipping.updated' => ...]
Step registrationstepNavigator.registerStep() in JS + layout XMLhyva_checkout_components.xml with parent + sortOrder
Re-render triggergetPaymentInformation() refetches /payment-informationMagewire automatic re-render on property change
TemplateKnockout HTML with data-bindPHTML with wire:model bindings
State locationClient (KO observables)Server (Magewire component properties)
Survives refreshNo — fetches fresh on reloadYes — Magewire re-mounts from quote
Skill requirementJS + KO + RequireJS familiarityPHP + a small Livewire-style DSL
Lines of frontend code~140 lines (JS + template + XML)~80 lines (PHP + PHTML + XML)

Operational gotchas we learned

  • Shipping-method change without address change — the customer can change shipping method without changing country. On Luma, that does not fire quote.shippingAddress.subscribe. Add a separate subscribe on quote.shippingMethod.
  • Coupon applied after payment selected — re-running eligibility on quote.totals change is critical. An Affirm-eligible cart can drop below the $50 floor after a discount and still proceed without it.
  • BNPL widget iframe — Klarna and Affirm render their own iframe inside the radio's expanded panel. Don't pre-mount them; let the customer click the radio first. The resolver returns metadata only.
  • 3DS redirect on non-BNPL methods — when the customer goes Stripe + 3DS, the redirect-back must restore the payment step. Use Magewire's defer hydration so the partial re-mounts from quote on the return leg.
  • Multi-shipping checkout — Magento's multi-shipping flow does not fire setShippingInformation the same way. Skip BNPL on multi-shipping carts; gateways do not support it anyway.

This pattern is for stores shipping 2+ BNPL gateways side-by-side that need them coordinated, or headless storefronts surfacing eligibility to a Next.js frontend. Klarna's official extension gates its own widget but does not coordinate with peers.

FAQ

Why not just hide ineligible methods in a payment method renderer?

You only know which are ineligible after shipping is set, which means the renderer flickers (mount, then hide) on every checkout. The step gate avoids the flicker and gives the customer a clear "confirm shipping to see options" message instead.

Will this slow checkout down?

The resolver adds 200–600ms on the first uncached call per country/total bucket. Subsequent calls within 60 seconds are cached. We measured no INP regression in Lighthouse CI. The step itself does not mount until after shipping is set, so it does not affect the initial checkout TTFB.

Can I use this with a headless PWA storefront?

Yes. The resolver is reachable via REST (/V1/carts/mine/bnpl-eligibility). Wire a GraphQL resolver around it if the storefront prefers GraphQL. The QuoteValidator plugin runs regardless of frontend.

What if a gateway returns 502 during eligibility check?

The client wraps the call in a 1-second timeout and returns null on failure. The resolver continues with the remaining gateways. A 502 from Klarna does not block Affirm + Sezzle from appearing.

Why bucket the cart total at 50 currency units?

BNPL floors and ceilings are coarse — Affirm's floor is $50, Sezzle's is $35. A 50-unit bucket means small cart edits do not invalidate the cache. The QuoteValidator plugin re-checks at submit to catch boundary cases.

Does Hyvä Checkout 2.x change the event name?

Hyvä Checkout 1.x dispatches shipping.updated. Hyvä Checkout 2.x (beta as of May 2026) renamed it to checkout.shipping.updated. Listen for both during migration.

Citations

  1. [1] Klarna Payments API — "Create a payment session" reference, requires purchase_country + order_amount. docs.klarna.com/api/payments
  2. [2] Adobe Commerce DevDocs — "Add a new payment method" and the Knockout step navigator. developer.adobe.com/commerce/frontend-core/guide/templates/checkout
  3. [3] Hyvä Checkout documentation — Magewire components, event dispatchers, and the component registry. docs.hyva.io/hyva-checkout
  4. [4] Adobe Commerce DevDocs — Magento\Quote\Model\QuoteValidator::validateBeforeSubmit reference. developer.adobe.com/commerce/php/api
  5. [5] Affirm Checkout API — minimum and maximum cart eligibility floors per market. docs.affirm.com/developers/docs/checkout-overview
Need this multi-step payment flow shipped on your store?

I scope and ship Magento 2.4.4 — 2.4.9 + Hyvä checkout customizations on kishansavaliya.com — including BNPL eligibility resolvers, custom step components for Luma, Magewire components for Hyvä, server-side validators, and a 30-day patch window. Fixed quote from $499 audit · $2,499 sprint · ~32h @ $25/hr. See hire me.