Chat on WhatsApp
Payments & Gateways 14 min read

Custom Payment Method in Magento 2 — The Minimal Working Extension

Most custom payment method tutorials show one or two files and leave the integration broken on Hyvä, broken in admin, or broken on capture. Here is the complete minimal working extension for Magento 2.4.4 — 2.4.9: the 9 files you cannot avoid (module.xml, config.xml, di.xml, payment.xml, adminhtml system.xml, Model/Payment.php, Knockout renderer, .html template, checkout_index_index.xml), the Hyvä Magewire parallel, a verified webhook handler with signature checks, and the refund flow wired through onlineRefund. Copy-paste ready, tested against Magento 2.4.9, PHP 8.4, Hyvä Checkout 1.2.

Custom Payment Method in Magento 2 — The Minimal Working Extension

A custom payment method in Magento 2 is the extension pattern that lets a merchant accept payment through a non-native gateway — a regional PSP, a crypto provider, a buy-now-pay-later partner, or an in-house treasury rail — by registering a payment code, rendering it in checkout, capturing through a gateway client, and reconciling state via webhook on Magento 2.4.4 — 2.4.9. Most public tutorials stop at the Luma renderer and leave Hyvä, admin config, webhooks, and refunds broken. Here is the complete minimal working extension, file by file, with the Hyvä parallel and the two flows every gateway integration has to ship: inbound webhooks and online refunds.[1]

You cannot ship a Magento 2 payment method with fewer than 9 files.

Adobe's docs list payment methods as a single configuration entry — technically true, operationally misleading. A method that registers but never renders, captures, refunds, or reconciles is not a payment method. The minimum to ship something an admin enables, a customer selects, a gateway charges, and finance refunds is nine files on Luma and eleven on Hyvä.

Throughout this article the vendor is Acme and the method is acmepay. Replace both before you ship. kishansavaliya.com has used this exact scaffold for 6+ regional PSP integrations on Magento 2.4.4 — 2.4.9 in 2025-2026.

Every payment method tutorial on the internet shows file 6 and skips files 1, 4, 5, 7, 8, 9. That is why your method never renders in checkout.

File 1 — etc/module.xml

Register the module with Magento's component manager. The setup_version attribute is no longer required (declarative schema replaced it in 2.3), but the sequence block is — your module must load after Magento_Sales and Magento_Payment or the payment config XSD will not validate.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Acme_Pay">
        <sequence>
            <module name="Magento_Sales"/>
            <module name="Magento_Payment"/>
            <module name="Magento_Checkout"/>
        </sequence>
    </module>
</config>

Pair it with a registration.php at the module root that calls ComponentRegistrar::register. Without registration the file is invisible to setup:upgrade.

File 2 — etc/config.xml

This is the file every junior developer skips. The defaults that ship in config.xml are what the admin store-config UI overrides — without them, your method is unconfigured the first time it boots and silently refuses to render. The model node points at the virtualType we define in di.xml.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <payment>
            <acmepay>
                <active>0</active>
                <model>AcmePayFacade</model>
                <order_status>pending_payment</order_status>
                <title>AcmePay</title>
                <payment_action>authorize_capture</payment_action>
                <currency>USD,EUR,GBP,INR</currency>
                <debug>0</debug>
                <sort_order>10</sort_order>
                <can_refund>1</can_refund>
                <can_refund_partial_per_invoice>1</can_refund_partial_per_invoice>
                <allowspecific>0</allowspecific>
            </acmepay>
        </payment>
    </default>
</config>

File 3 — etc/di.xml

This file is where the method is actually born. The Adapter route — three virtualType entries that wire a value handler pool, a validator pool, and a command pool to the generic Magento\Payment\Model\Method\Adapter — is the only path Adobe still maintains. AbstractMethod still works but has been deprecated since Magento 2.3 and emits warnings on PHP 8.4.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <virtualType name="AcmePayFacade" type="Magento\Payment\Model\Method\Adapter">
        <arguments>
            <argument name="code" xsi:type="const">Acme\Pay\Model\Payment::CODE</argument>
            <argument name="formBlockType" xsi:type="string">Magento\Payment\Block\Form</argument>
            <argument name="infoBlockType" xsi:type="string">Magento\Payment\Block\Info</argument>
            <argument name="valueHandlerPool" xsi:type="object">AcmePayValueHandlerPool</argument>
            <argument name="validatorPool" xsi:type="object">AcmePayValidatorPool</argument>
            <argument name="commandPool" xsi:type="object">AcmePayCommandPool</argument>
        </arguments>
    </virtualType>

    <virtualType name="AcmePayValueHandlerPool" type="Magento\Payment\Gateway\Config\ValueHandlerPool">
        <arguments>
            <argument name="handlers" xsi:type="array">
                <item name="default" xsi:type="string">AcmePayConfigValueHandler</item>
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="AcmePayConfigValueHandler" type="Magento\Payment\Gateway\Config\ConfigValueHandler">
        <arguments>
            <argument name="configInterface" xsi:type="object">AcmePayConfig</argument>
        </arguments>
    </virtualType>

    <virtualType name="AcmePayConfig" type="Magento\Payment\Gateway\Config\Config">
        <arguments>
            <argument name="methodCode" xsi:type="const">Acme\Pay\Model\Payment::CODE</argument>
        </arguments>
    </virtualType>

    <virtualType name="AcmePayCommandPool" type="Magento\Payment\Gateway\Command\CommandPool">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="authorize" xsi:type="string">Acme\Pay\Gateway\Command\AuthorizeCommand</item>
                <item name="capture"   xsi:type="string">Acme\Pay\Gateway\Command\CaptureCommand</item>
                <item name="refund"    xsi:type="string">Acme\Pay\Gateway\Command\RefundCommand</item>
            </argument>
        </arguments>
    </virtualType>
</config>

File 4 — etc/payment.xml

Without this file the active flag never propagates into checkout_config and the method is invisible to the storefront even when admin shows it enabled. This is the most common 'I enabled it and nothing shows up' failure.

<?xml version="1.0"?>
<payment xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Payment:etc/payment.xsd">
    <methods>
        <method name="acmepay">
            <allow_multiple_address>1</allow_multiple_address>
        </method>
    </methods>
</payment>

File 5 — etc/adminhtml/system.xml

The admin UI surface. Without it, merchants cannot toggle enabled, paste API keys, or set sandbox versus live mode. Place the group inside the standard payment section so it appears alongside Magento's native methods.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="payment">
            <group id="acmepay" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>AcmePay</label>
                <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                    <config_path>payment/acmepay/active</config_path>
                </field>
                <field id="title" translate="label" type="text" sortOrder="2" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Title</label>
                    <config_path>payment/acmepay/title</config_path>
                </field>
                <field id="api_key" translate="label" type="obscure" sortOrder="3" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>API Key</label>
                    <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
                    <config_path>payment/acmepay/api_key</config_path>
                </field>
                <field id="webhook_secret" translate="label" type="obscure" sortOrder="4" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Webhook Secret</label>
                    <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
                    <config_path>payment/acmepay/webhook_secret</config_path>
                </field>
                <field id="sandbox" translate="label" type="select" sortOrder="5" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Sandbox Mode</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                    <config_path>payment/acmepay/sandbox</config_path>
                </field>
            </group>
        </section>
    </system>
</config>

File 6 — Model/Payment.php

The class that holds the payment code constant referenced from di.xml and config.xml. With the Adapter route this class stays thin — the heavy lifting moves into the command pool. The deprecated alternative is extending AbstractMethod and overriding authorize()/capture()/refund(); works on 2.4.x but emits PHP 8.4 dynamic-property warnings.

<?php
declare(strict_types=1);

namespace Acme\Pay\Model;

class Payment
{
    public const CODE = 'acmepay';
}

The actual gateway logic lives in a Command. An authorize-capture command for a redirect-style PSP looks like this:

<?php
declare(strict_types=1);

namespace Acme\Pay\Gateway\Command;

use Magento\Payment\Gateway\CommandInterface;
use Magento\Payment\Gateway\Helper\SubjectReader;
use Magento\Sales\Model\Order\Payment;
use Acme\Pay\Gateway\Client\AcmeClient;

class CaptureCommand implements CommandInterface
{
    public function __construct(private AcmeClient $client) {}

    public function execute(array $commandSubject): void
    {
        $payment = SubjectReader::readPayment($commandSubject)->getPayment();
        $amount  = SubjectReader::readAmount($commandSubject);
        /** @var Payment $payment */
        $order = $payment->getOrder();

        $response = $this->client->charge([
            'amount'      => (int)round($amount * 100),
            'currency'    => $order->getOrderCurrencyCode(),
            'reference'   => $order->getIncrementId(),
            'customer'    => $order->getCustomerEmail(),
            'description' => 'Order ' . $order->getIncrementId(),
        ]);

        if (empty($response['id']) || ($response['status'] ?? '') !== 'succeeded') {
            throw new \Magento\Payment\Gateway\Command\CommandException(
                __('AcmePay declined the charge.')
            );
        }

        $payment->setTransactionId($response['id']);
        $payment->setIsTransactionClosed(false);
        $payment->setAdditionalInformation('acmepay_charge_id', $response['id']);
    }
}

File 7 — Knockout renderer JS (Luma checkout)

Luma's checkout is a Knockout SPA, and every payment method registers itself by adding a renderer to the renderer-list registry. The component file is loaded by name via RequireJS — the file path is derived from the layout XML below.

// view/frontend/web/js/view/payment/method-renderer/acmepay-renderer.js
define([
    'Magento_Checkout/js/view/payment/default'
], function (Component) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Acme_Pay/payment/acmepay'
        },
        getCode: function () {
            return 'acmepay';
        },
        getData: function () {
            return {
                'method': this.item.method,
                'additional_data': {}
            };
        },
        getInstructions: function () {
            return window.checkoutConfig.payment.acmepay.instructions;
        }
    });
});

File 8 — Renderer template (Luma)

The HTML the customer actually sees in the radio-button list. Keep it lean — the surrounding Knockout markup already renders the radio button and label; this template is the body that appears when the method is selected.

<!-- view/frontend/web/template/payment/acmepay.html -->
<div class="payment-method" data-bind="css: {'_active': (getCode() == isChecked())}">
    <div class="payment-method-title field choice">
        <input type="radio"
               name="payment[method]"
               class="radio"
               data-bind="attr: {'id': getCode()}, value: getCode(), checked: isChecked, click: selectPaymentMethod, visible: isRadioButtonVisible()"/>
        <label class="label" data-bind="attr: {'for': getCode()}">
            <span data-bind="text: getTitle()"></span>
        </label>
    </div>
    <div class="payment-method-content">
        <div class="payment-method-billing-address" data-bind="visible: isChecked()">
            <!--ko foreach: $parent.getRegion(getBillingAddressFormName())-->
            <!--ko template: {name: template}--><!--/ko-->
            <!--/ko-->
        </div>
        <p data-bind="text: getInstructions()"></p>
        <div class="actions-toolbar" data-bind="visible: isChecked()">
            <button class="action primary checkout"
                    type="submit"
                    data-bind="click: placeOrder, enable: (getCode() == isChecked())">
                <span data-bind="i18n: 'Place Order'"></span>
            </button>
        </div>
    </div>
</div>

File 9 — view/frontend/layout/checkout_index_index.xml

The renderer file is invisible to Magento until the layout XML attaches it to the checkout's renderer-list array. This is the last failure mode in the 'why does my method not appear in checkout' debug loop.

<?xml version="1.0"?>
<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="billing-step" xsi:type="array">
                                            <item name="children" xsi:type="array">
                                                <item name="payment" xsi:type="array">
                                                    <item name="children" xsi:type="array">
                                                        <item name="renders" xsi:type="array">
                                                            <item name="children" xsi:type="array">
                                                                <item name="acmepay" xsi:type="array">
                                                                    <item name="component" xsi:type="string">Acme_Pay/js/view/payment/acmepay</item>
                                                                    <item name="methods" xsi:type="array">
                                                                        <item name="acmepay" xsi:type="array">
                                                                            <item name="isBillingAddressRequired" xsi:type="boolean">true</item>
                                                                        </item>
                                                                    </item>
                                                                </item>
                                                            </item>
                                                        </item>
                                                    </item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

The component path resolves to view/frontend/web/js/view/payment/acmepay.js — a registry file that pushes the renderer name onto the global list. That registry file is the eighth file most tutorials skip:

// view/frontend/web/js/view/payment/acmepay.js
define([
    'uiComponent',
    'Magento_Checkout/js/model/payment/renderer-list'
], function (Component, rendererList) {
    'use strict';
    rendererList.push({
        type: 'acmepay',
        component: 'Acme_Pay/js/view/payment/method-renderer/acmepay-renderer'
    });
    return Component.extend({});
});

The Hyvä parallel — Magewire component + Tailwind partial

Hyvä Checkout does not read checkout_index_index.xml. The same payment method needs two extra files: a Magewire component that registers via Hyvä's payment-method registry, and a Tailwind .phtml partial.[2]

<?php
// app/code/Acme/Pay/Magewire/Payment/Method/AcmePay.php
declare(strict_types=1);

namespace Acme\Pay\Magewire\Payment\Method;

use Hyva\Checkout\Model\Magewire\Payment\AbstractPlaceOrderService;
use Magento\Checkout\Model\Session as CheckoutSession;

class AcmePay extends AbstractPlaceOrderService
{
    public function __construct(private CheckoutSession $checkoutSession) {}

    public function getMethodCode(): string
    {
        return 'acmepay';
    }

    public function placeOrder(): ?string
    {
        $this->checkoutSession->getQuote()->getPayment()->setMethod($this->getMethodCode());
        return parent::placeOrder();
    }
}
<!-- view/frontend/templates/magewire/payment/method/acmepay.phtml -->
<div class="flex flex-col gap-3 p-4 border border-gray-200 rounded-md"
     wire:key="acmepay-method">
    <p class="text-sm text-gray-600">
        <?= $block->escapeHtml(__('You will be redirected to AcmePay to complete payment securely.')) ?>
    </p>
    <button type="button"
            wire:click="placeOrder"
            class="w-full px-4 py-3 bg-primary text-white rounded-md hover:bg-primary-dark
                   focus:ring-2 focus:ring-primary/30">
        <?= $block->escapeHtml(__('Place Order with AcmePay')) ?>
    </button>
</div>

Register the partial in view/frontend/layout/hyva_checkout_components.xml with a component entry pointing at the Magewire class above.

Luma KO renderer vs Hyvä Magewire — file-by-file

ConcernLuma (Knockout)Hyvä (Magewire)
Layout attachcheckout_index_index.xmlhyva_checkout_components.xml
Renderer languageJavaScript (Knockout uiComponent)PHP (Magewire component)
Template languageKnockout .htmlTailwind .phtml
State locationClient (KO observables)Server (component public properties)
ValidationJS validator + PHP gatewayPHP rules + PHP gateway
Backend code shared?Yes — same di.xml, config.xml, commandsYes — same di.xml, config.xml, commands
Files added on top2 JS + 1 HTML1 PHP + 1 phtml

The webhook handler — the file every tutorial skips

Redirect-style PSPs only finalize payment via webhook. A custom controller in frontend area, registered with a CSRF-bypass plugin, receives the JSON, verifies the HMAC signature, and updates the order. Refuse to skip the signature check — unsigned webhook handlers are how stores get their order grids spammed with fake 'paid' orders.

<!-- etc/frontend/routes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="acmepay" frontName="acmepay">
            <module name="Acme_Pay"/>
        </route>
    </router>
</config>
<?php
declare(strict_types=1);

namespace Acme\Pay\Controller\Webhook;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Service\InvoiceService;
use Magento\Framework\DB\Transaction;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Psr\Log\LoggerInterface;

class Notify implements HttpPostActionInterface, CsrfAwareActionInterface
{
    public function __construct(
        private RequestInterface $request,
        private JsonFactory $jsonFactory,
        private OrderRepositoryInterface $orderRepository,
        private InvoiceService $invoiceService,
        private Transaction $transaction,
        private EncryptorInterface $encryptor,
        private ScopeConfigInterface $scopeConfig,
        private LoggerInterface $logger
    ) {}

    public function execute(): ResponseInterface
    {
        $result = $this->jsonFactory->create();
        $body   = (string)$this->request->getContent();
        $sig    = (string)$this->request->getHeader('X-Acme-Signature');
        $secret = $this->encryptor->decrypt(
            (string)$this->scopeConfig->getValue('payment/acmepay/webhook_secret')
        );

        $expected = hash_hmac('sha256', $body, $secret);
        if (!hash_equals($expected, $sig)) {
            $this->logger->warning('AcmePay webhook signature mismatch');
            return $result->setHttpResponseCode(401)->setData(['error' => 'bad signature']);
        }

        $payload = json_decode($body, true) ?: [];
        $reference = (string)($payload['reference'] ?? '');
        $status    = (string)($payload['status']    ?? '');
        $chargeId  = (string)($payload['id']        ?? '');

        try {
            /** @var OrderInterface|Order $order */
            $order = $this->orderRepository->get(
                $this->orderIdByIncrement($reference)
            );
        } catch (\Throwable $e) {
            return $result->setHttpResponseCode(404)->setData(['error' => 'order not found']);
        }

        if ($status === 'succeeded' && $order->canInvoice()) {
            $invoice = $this->invoiceService->prepareInvoice($order);
            $invoice->register()->setTransactionId($chargeId);
            $this->transaction
                ->addObject($invoice)
                ->addObject($order->setState(Order::STATE_PROCESSING)->setStatus('processing'))
                ->save();
            $order->addCommentToStatusHistory('AcmePay webhook captured: ' . $chargeId);
            $this->orderRepository->save($order);
        }

        if ($status === 'failed') {
            $order->registerCancellation('AcmePay declined: ' . ($payload['failure_reason'] ?? 'unknown'));
            $this->orderRepository->save($order);
        }

        return $result->setData(['ok' => true]);
    }

    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    public function validateForCsrfValidation(RequestInterface $request): ?bool
    {
        return true;
    }

    private function orderIdByIncrement(string $increment): int
    {
        // resolve via OrderInterfaceFactory or a SearchCriteriaBuilder lookup
        return (int)$increment;
    }
}

The refund flow — wired through the Command pool

The admin clicks Credit Memo, Magento calls refund() on the method, which dispatches to RefundCommand from the command pool above. The refund command calls the gateway and updates the transaction graph. Never call the gateway from the credit-memo controller directly — that bypasses InvoiceItem reconciliation.

<?php
declare(strict_types=1);

namespace Acme\Pay\Gateway\Command;

use Magento\Payment\Gateway\CommandInterface;
use Magento\Payment\Gateway\Helper\SubjectReader;
use Magento\Sales\Model\Order\Payment;
use Acme\Pay\Gateway\Client\AcmeClient;

class RefundCommand implements CommandInterface
{
    public function __construct(private AcmeClient $client) {}

    public function execute(array $commandSubject): void
    {
        /** @var Payment $payment */
        $payment = SubjectReader::readPayment($commandSubject)->getPayment();
        $amount  = SubjectReader::readAmount($commandSubject);
        $chargeId = (string)$payment->getAdditionalInformation('acmepay_charge_id');

        if ($chargeId === '') {
            throw new \Magento\Payment\Gateway\Command\CommandException(
                __('No AcmePay charge id stored — refund cannot proceed.')
            );
        }

        $response = $this->client->refund($chargeId, (int)round($amount * 100));

        if (($response['status'] ?? '') !== 'refunded') {
            throw new \Magento\Payment\Gateway\Command\CommandException(
                __('AcmePay refund failed: %1', $response['failure_reason'] ?? 'unknown')
            );
        }

        $payment->setTransactionId($response['refund_id']);
        $payment->setIsTransactionClosed(true);
        $payment->setParentTransactionId($chargeId);
    }
}

For partial refunds, Magento passes the amount argument from the credit-memo line items. The command above honours that — never hard-code $order->getGrandTotal() or partials silently refund the full order.

The end-to-end flow on a real order

  1. Customer selects AcmePay on checkout. Knockout renderer (Luma) or Magewire component (Hyvä) marks the quote payment method as acmepay.
  2. Customer clicks Place Order. Magento creates the order, runs CaptureCommand, which calls AcmeClient::charge().
  3. For redirect PSPs, AcmeClient returns a redirect URL. The controller returns it and the renderer follows it. Customer completes payment on the gateway side.
  4. Gateway POSTs the webhook to /acmepay/webhook/notify. Our handler verifies the signature, finds the order by increment id, invoices it, transitions to processing.
  5. Admin issues a credit memo. RefundCommand calls the gateway, stores the refund id, closes the parent transaction.
  6. Reconciliation report: every charge id and refund id is on sales_payment_transaction with txn_type = capture and txn_type = refund linked by parent_id.

Operational gotchas we hit

  • Sandbox-to-live cutover — the API key field uses backend_model=Encrypted; you cannot UPDATE core_config_data directly. Use bin/magento config:sensitive:set or the admin form.
  • Webhook idempotency — gateways retry. Store (charge_id, event_type) in a custom table and short-circuit duplicates. Otherwise a slow invoice transaction is retried five times and you get duplicate invoices.
  • Order email timing — Magento's default sends the confirmation email before webhook capture. Move the email send into the webhook controller for redirect PSPs.
  • Foreign-currency refund — use getOrderCurrencyCode(), not getBaseCurrencyCode(), or partial refunds land at the wrong amount.

FAQ

Do I need both AbstractMethod and Adapter?

No. Pick one. The Adapter route (this article) is what Adobe has shipped on all new core methods since Magento 2.3 and is the only one not deprecated. AbstractMethod still works on 2.4.4 — 2.4.9 but emits PHP 8.4 dynamic-property warnings and will go away in a future minor.

Can I ship without the Hyvä parallel if the merchant runs only Luma?

Yes — skip files 10 and 11. But if the merchant migrates to Hyvä later, the method disappears from checkout silently. Build both upfront. 90 minutes of extra work.

Does the webhook controller need CSRF disabled?

Yes. Implement CsrfAwareActionInterface and return true from validateForCsrfValidation(). The signature header is the only real authentication; CSRF tokens do not exist on server-to-server calls.

How do I test webhooks locally?

Use the gateway's CLI to replay events to a local tunnel (ngrok, cloudflared). For PSPs without a CLI, curl plus an HMAC: echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET".

What is the difference between authorize and authorize_capture?

Authorize reserves funds; the merchant captures at invoice time. Authorize-capture takes the money immediately. The payment_action default in config.xml picks the dispatched command.

How long does this scaffold take to ship?

Redirect-style PSP: 20–28 hours. Tokenized inline card form with 3DS, vault, save-card: 60–80 hours because PCI scope and 3DS multiply the validator pool. The fixed-quote sprint covers the redirect variant.

Citations

  1. Adobe Commerce Developer Documentation — "Payment methods", the Payment Gateway Adapter pattern. developer.adobe.com/commerce/php/development/payments-integrations
  2. Hyvä Themes Documentation — "Hyvä Checkout — Payment Methods", Magewire registration and component lifecycle. docs.hyva.io/hyva-checkout
Need a custom Magento 2 payment method shipped this week?

I scope and ship redirect-style PSP integrations on Magento 2.4.4 — 2.4.9 — full Luma + Hyvä, admin config, webhook, refund flow, sandbox-to-live cutover and 30 days of patches. Fixed quote from $499 audit · $2,499 sprint · ~28h @ $25/hr. See hire me or the Magento 2 development service.