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.
Table of Contents
- 1. Why a custom payment method integration is more than a form
- 2. The Payment Gateway Command Pattern at a glance
- 3. ConfigProvider: passing configuration to the frontend
- 4. Registering commands: authorize, capture, cancel
- 5. Value handlers and response validators for gateway responses
- 6. Rendering the payment method in the Hyvä checkout frontend
- 7. Processing asynchronous status updates via webhook
- 8. Error handling and idempotency in payment requests
- 9. Custom integration compared to a payment bridge
- 10. Summary
- 11. FAQ
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.