Custom Payment Method Integration in the Magento 2 Checkout
AI generated
M2
di.xml
Magento 2 · Payment Gateway · Command Pattern · Hyvä
Custom payment method integration
built cleanly in the Magento 2 checkout

A custom payment method integration in Magento 2 is not a simple form with account details, it is a complete gateway connection with authorization, capture, cancellation and asynchronous status updates. The Payment Gateway Command Pattern structures these building blocks so an external payment provider can be connected without any core overrides.

19 min read ConfigProvider · Command Pool · Value Handler · Webhook Magento 2.4.8-p4 · PHP 8.4

1. Why a custom payment method integration is more than a form

Anyone building a payment method integration for the first time often underestimates how many state transitions a payment actually goes through. An order gets authorized, partially or fully captured, possibly cancelled, later partially refunded, and the external payment provider frequently reports status changes asynchronously via webhook, independent of the original checkout request. A payment method integration that only covers the authorization case falls apart at the latest with the first refund or the first delayed payment confirmation.

Magento addresses this complexity with the Payment Gateway Command Pattern, which since Magento 2.1 has replaced the old AbstractMethod class as the recommended way to build a new payment method integration. Instead of a monolithic payment method class with many methods, a collection of small, individually testable commands is created, each mapping exactly one state transition. This lines up exactly with the project's preference for service contracts and clearly scoped responsibilities over large god classes.

This article walks through the full construction of a payment method integration for a fictional payment provider: a ConfigProvider for frontend configuration, command registration for authorize, capture and cancel, value handlers and response validators for gateway responses, integration with the Hyvä checkout frontend, and webhook processing plus idempotency. Everything is oriented around Magento 2.4.8-p4 with PHP 8.4 and constructor property promotion.

2. The Payment Gateway Command Pattern at a glance

At the center of every modern payment method integration sits the CommandPool, a registry that maps a state transition, such as authorize or capture, to a concrete command class. Every command implements CommandInterface with exactly one execute() method and receives a PaymentDataObject as argument, which wraps order, payment and amount. This strict one to one mapping between state transition and command class is the core of the command pattern and makes every single operation independently testable.

Alongside the CommandPool there is the ConfigProvider, which passes configuration values such as the API endpoint or the public key to the checkout frontend, plus ValueHandlerPool and ResponseValidatorPool, which translate gateway responses into Magento understandable values or success and failure results. These four building blocks together form a complete payment method integration, without a single core class of Magento ever needing to be overridden.

The decisive advantage over the old AbstractMethod: every new requirement, for example an additional state transition for partial refunds, results in a new command instead of yet another method on a growing base class. The payment method integration stays understandable this way and individually testable even after years of extensions.

3. ConfigProvider: passing configuration to the frontend

The ConfigProvider is the bridge between server side configuration and the checkout frontend. It implements ConfigProviderInterface with a single getConfig() method that returns a nested array, later passed to the frontend as JSON. For a payment method integration, this typically includes the public API key, the sandbox or live mode, and supported card types, but never secret credentials such as a secret key that must only ever be used server side.

This separation is security relevant: everything the ConfigProvider returns ends up unencrypted in the HTML of the checkout page and is therefore visible to any browser client. A payment method integration that accidentally exposes a secret key through the ConfigProvider opens a serious security hole, regardless of how well the rest of the integration is secured.


<?php

declare(strict_types=1);

namespace Mironsoft\PaymentGateway\Model;

use Magento\Checkout\Model\ConfigProviderInterface;
use Magento\Payment\Helper\Data as PaymentHelper;
use Magento\Payment\Model\MethodInterface;

/**
 * Provides public, non-secret gateway configuration to the checkout frontend.
 */
class GatewayConfigProvider implements ConfigProviderInterface
{
    private const METHOD_CODE = 'mironsoft_gateway';

    private readonly MethodInterface $method;

    /**
     * @param PaymentHelper $paymentHelper
     */
    public function __construct(
        private readonly PaymentHelper $paymentHelper
    ) {
        $this->method = $this->paymentHelper->getMethodInstance(self::METHOD_CODE);
    }

    /**
     * Return only public configuration values, never secret credentials.
     *
     * @return array<string, mixed>
     */
    public function getConfig(): array
    {
        if (!$this->method->isAvailable()) {
            return [];
        }

        return [
            'payment' => [
                self::METHOD_CODE => [
                    'publicKey' => (string) $this->method->getConfigData('public_key'),
                    'sandbox' => (bool) $this->method->getConfigData('sandbox_mode'),
                    'supportedCardTypes' => explode(',', (string) $this->method->getConfigData('cctypes')),
                ],
            ],
        ];
    }
}

4. Registering commands: authorize, capture, cancel

After configuration comes the actual core of every payment method integration: the commands. Each command wraps exactly one HTTP call against the payment provider's API and translates request and response between Magento data structures and the external gateway format. Commands are registered declaratively through di.xml via a virtual type of the CommandPool, with no preferences on core classes whatsoever.

Important for a clean payment method integration: the actual HTTP communication does not belong in the command, it belongs in a separate client service that the command receives injected through constructor property promotion. That way, the command stays limited to translation logic between Magento and the gateway, while the client can be tested independently and swapped out for a different HTTP library if needed.


<?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="MironsoftGatewayCommandPool" type="Magento\Payment\Gateway\Command\CommandPool">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="authorize" xsi:type="string">Mironsoft\PaymentGateway\Gateway\Command\AuthorizeCommand</item>
                <item name="capture" xsi:type="string">Mironsoft\PaymentGateway\Gateway\Command\CaptureCommand</item>
                <item name="cancel" xsi:type="string">Mironsoft\PaymentGateway\Gateway\Command\CancelCommand</item>
            </argument>
        </arguments>
    </virtualType>

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

The AuthorizeCommand itself performs the API call, checks the response for a successful authorization code, and on success writes the gateway transaction id as lastTransId onto the payment object. This transaction id is later needed by capture and cancel to uniquely reference the original authorization.


<?php

declare(strict_types=1);

namespace Mironsoft\PaymentGateway\Gateway\Command;

use Magento\Payment\Gateway\CommandInterface;
use Magento\Payment\Gateway\Data\PaymentDataObjectInterface;
use Magento\Payment\Gateway\Command\ResultInterface;
use Mironsoft\PaymentGateway\Gateway\Http\GatewayClientInterface;

/**
 * Authorizes a payment against the external gateway and stores the
 * resulting transaction id on the Magento payment for later capture calls.
 */
class AuthorizeCommand implements CommandInterface
{
    /**
     * @param GatewayClientInterface $client
     */
    public function __construct(
        private readonly GatewayClientInterface $client
    ) {
    }

    /**
     * @param array{payment: PaymentDataObjectInterface, amount: float} $commandSubject
     * @return ResultInterface|null
     */
    public function execute(array $commandSubject): ?ResultInterface
    {
        /** @var PaymentDataObjectInterface $paymentDataObject */
        $paymentDataObject = $commandSubject['payment'];
        $payment = $paymentDataObject->getPayment();
        $amount = (float) $commandSubject['amount'];

        $response = $this->client->authorize([
            'order_reference' => $paymentDataObject->getOrder()->getOrderIncrementId(),
            'amount' => $amount,
            'currency' => $paymentDataObject->getOrder()->getCurrencyCode(),
        ]);

        $payment->setTransactionId($response['transaction_id']);
        $payment->setIsTransactionClosed(false);

        return null;
    }
}

5. Value handlers and response validators for gateway responses

A value handler translates a single configuration value, for example whether the payment method integration is currently running in sandbox mode, into a value Magento can query elsewhere, such as in the admin grid or the invoice view. Response validators, on the other hand, check the complete response of a gateway call and decide whether it counts as success or failure, independent of the HTTP status code, which some payment providers return as 200 even for business level failures.

This separation is crucial: an HTTP 200 response with a status: declined field in the body is, from the perspective of the payment method integration, a business level failure, even though the transport layer succeeded. A response validator that only checks the HTTP status code would incorrectly mark a declined payment as successfully authorized, with correspondingly fatal consequences for the rest of the order process.


<?php

declare(strict_types=1);

namespace Mironsoft\PaymentGateway\Gateway\Validator;

use Magento\Payment\Gateway\Validator\AbstractValidator;
use Magento\Payment\Gateway\Validator\ResultInterface;
use Magento\Payment\Gateway\Validator\ResultInterfaceFactory;

/**
 * Validates the business level status inside a gateway response, independent
 * of the HTTP transport status which may be 200 even for declined payments.
 */
class AuthorizeResponseValidator extends AbstractValidator
{
    /**
     * @param ResultInterfaceFactory $resultFactory
     */
    public function __construct(
        private readonly ResultInterfaceFactory $resultFactory
    ) {
        parent::__construct($resultFactory);
    }

    /**
     * @param array{response: array<string, mixed>} $validationSubject
     * @return ResultInterface
     */
    public function validate(array $validationSubject): ResultInterface
    {
        $response = $validationSubject['response'];
        $isValid = ($response['status'] ?? null) === 'approved';

        $fails = [];
        if (!$isValid) {
            $fails[] = sprintf('Gateway declined the authorization: %s', $response['decline_reason'] ?? 'unknown');
        }

        return $this->createResult($isValid, $fails);
    }
}

6. Rendering the payment method in the Hyvä checkout frontend

The final step of every payment method integration is its presentation in the checkout itself. In a Luma setup, that would be a KnockoutJS component registered as another renderer in the payment step. In a Hyvä project, that path disappears entirely: the payment method is implemented as a Magewire component that renders the method's name, its logo, and an Alpine.js template for any additional fields, for example a field for an order reference when paying on account.

Selecting the payment method itself triggers a server side call through Magewire that sets the chosen method on the quote, without an additional REST request from the frontend being necessary. That noticeably reduces the number of network round trips compared to the classic Knockout checkout, where payment selection and the later order placement are typically separate requests.

7. Processing asynchronous status updates via webhook

Many payment providers do not confirm a payment synchronously within the original checkout request, they report the final status later via webhook instead, for example when an instant bank transfer only gets confirmed after several seconds, or a chargeback arrives days later. A complete payment method integration therefore needs its own controller that accepts webhook calls, verifies the provider's signature, and updates the order status accordingly.

Signature verification is not optional here: without it, anyone who knows the webhook URL could inject arbitrary payment status updates and, for example, artificially mark an unpaid order as paid. The webhook controller should therefore consistently check the HMAC header sent along by the payment provider against its own secret key before making any status change to the order at all.


#!/usr/bin/env bash
# Local test call against the webhook endpoint with a valid HMAC signature
set -euo pipefail

PAYLOAD='{"order_reference":"000000123","status":"captured","transaction_id":"tx_9f8e7d"}'
SECRET="whsec_test_only_do_not_use_in_production"
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

curl -s -X POST "https://shop.example.com/mironsoft_gateway/webhook/notify" \
  -H "Content-Type: application/json" \
  -H "X-Gateway-Signature: $SIGNATURE" \
  -d "$PAYLOAD"

8. Error handling and idempotency in payment requests

Networks are unreliable, and a payment method integration has to account for a request reaching the gateway while the response never reaches Magento because of a timeout. Without idempotency protection, an automatic retry would in this case trigger a second authorization and charge the customer twice. The solution is a unique idempotency key per order attempt, sent by the gateway client with every request, so the payment provider recognizes a repeated request with the same key as a duplicate and returns the original response again instead of executing the payment a second time.

In addition, every command in the payment method integration should explicitly distinguish network errors from business level declines. A connection failure should raise a CommandException that the checkout presents to the customer as a temporary error with a retry option, while a business level decline, for example due to insufficient funds, should be treated as a final result with a clear error message. Treating both cases the same confuses customers either with a retry option on a finally declined payment or with a final error message on a temporary network problem.

9. Custom integration compared to a payment bridge

Not every project needs a fully custom payment method integration. For established payment providers, ready made Magento extensions often already exist that implement the same command pattern. The following table shows when building it yourself pays off.

Criterion Ready made payment extension Custom payment method integration Recommendation
Established provider (PayPal, Adyen) Available, maintained High effort with no added value Use the ready made extension
Niche or local provider Often not available Necessary Build a custom integration
Full control over error handling Depends on the extension vendor Fully controllable Build custom for special requirements
Maintenance effort over time Carried by the vendor Carried by your own team Plan resources realistically

In practice, the right decision rarely comes down to technical feasibility, it comes down to long term maintenance responsibility. A custom payment method integration for an established global provider ties up development time that a ready made, regularly updated extension usually covers more cheaply and reliably.

Mironsoft

Magento 2 payment gateway development and checkout integration

A custom payment method integration for your checkout?

We build complete payment method integrations following the Payment Gateway Command Pattern, from commands through webhook processing to integration with your Hyvä checkout frontend.

Command Pattern

Authorize, capture and cancel as clean, individually testable commands

Webhook handling

Signature verification and idempotency for asynchronous status updates

Hyvä frontend

Magewire component instead of a Knockout renderer for the payment method

10. Summary

A solid payment method integration in Magento 2 is built on the Payment Gateway Command Pattern: a ConfigProvider for frontend configuration, a CommandPool for authorize, capture and cancel, value handlers and response validators for gateway responses. Every building block stays small, individually testable, and is registered exclusively through di.xml, without overriding a single core class.

Two aspects decide the reliability of every payment method integration in practice: correctly verified webhook processing for asynchronous status updates, and consistent idempotency protection against duplicate charges on network errors. Whoever plans both of these in from the start builds a payment method integration that stays correct even under load and on unreliable networks.

Custom payment method integration in Magento 2, the essentials at a glance

Architecture

ConfigProvider, CommandPool, value handler and response validator replace the old AbstractMethod class.

Security

Never expose secret keys through the ConfigProvider, verify webhook signatures consistently.

Reliability

Idempotency keys prevent duplicate charges on network errors and automatic retries.

Hyvä frontend

Magewire component instead of a Knockout renderer, one server side call instead of separate REST requests.

11. FAQ: Custom Payment Method Integration in Magento 2

1How do I build a custom payment method integration?
Through the Payment Gateway Command Pattern with ConfigProvider, CommandPool, value handler and response validator.
2Why not AbstractMethod anymore?
AbstractMethod grows into a hard to test god class, the command pattern splits each operation independently.
3What must the ConfigProvider not expose?
Never secret keys, everything in the ConfigProvider ends up unencrypted in the checkout page HTML.
4What does a response validator do?
Checks the business level response independent of the HTTP status code, catches declined payments despite HTTP 200.
5How is the payment method shown in Hyvä?
As a Magewire component with an Alpine.js template instead of a KnockoutJS renderer.
6Why a webhook endpoint?
For asynchronously confirmed payment status, independent of the original checkout request.
7How do I secure the webhook?
Through HMAC signature verification against your own secret key before any status change.
8What is an idempotency key?
A unique key per order attempt that prevents duplicate charges on retries after timeouts.
9Network error vs. business decline?
Network errors with a retry option, business declines as a final result with a clear error message.
10Build custom or use a ready made extension?
Build custom for niche providers or special requirements, use a ready made extension for established global providers.