Building a custom payment method in Magento 2 is one of the more complex integrations you can do on the platform. It touches frontend JavaScript, backend PHP, REST API configuration, and security requirements. I walk through the complete implementation – authorize, capture, refund, the JS renderer for checkout, and PCI DSS considerations.
Module structure
Vendor/Payment/
etc/
config.xml - default configuration values
adminhtml/system.xml - admin settings UI
di.xml
module.xml
Model/
Payment/
Method.php - main payment method class (extends AbstractMethod)
Api/
Client.php - HTTP client for the gateway API
Config.php - configuration helper
view/
frontend/
web/js/view/payment/
method-renderer/
vendor-payment.js - Knockout JS component
web/template/payment/
vendor-payment.html - checkout form template
config.xml – default values
<config>
<default>
<payment>
<vendor_payment>
<active>0</active>
<model>Vendor\Payment\Model\Payment\Method</model>
<order_status>pending_payment</order_status>
<payment_action>authorize</payment_action>
<title>Credit Card</title>
<allowspecific>0</allowspecific>
<can_use_checkout>1</can_use_checkout>
<can_authorize>1</can_authorize>
<can_capture>1</can_capture>
<can_refund>1</can_refund>
<can_void>1</can_void>
</vendor_payment>
</payment>
</default>
</config>
Payment Method class
<?php
declare(strict_types=1);
namespace Vendor\Payment\Model\Payment;
use Magento\Payment\Model\Method\AbstractMethod;
class Method extends AbstractMethod
{
protected $_code = 'vendor_payment';
protected $_isGateway = true;
protected $_canAuthorize = true;
protected $_canCapture = true;
protected $_canCapturePartial = false;
protected $_canRefund = true;
protected $_canRefundInvoicePartial = true;
protected $_canVoid = true;
protected $_canUseCheckout = true;
protected $_isInitializeNeeded = false;
public function __construct(
\Magento\Framework\Model\Context $context,
\Magento\Framework\Registry $registry,
\Magento\Framework\Api\ExtensionAttributesFactory $extensionFactory,
\Magento\Framework\Api\AttributeValueFactory $customAttributeFactory,
\Magento\Payment\Helper\Data $paymentData,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Payment\Model\Method\Logger $logger,
private \Vendor\Payment\Model\Api\Client $apiClient,
array $data = []
) {
parent::__construct($context, $registry, $extensionFactory, $customAttributeFactory,
$paymentData, $scopeConfig, $logger, null, null, $data);
}
public function authorize(\Magento\Payment\Model\InfoInterface $payment, $amount): static
{
try {
$token = $payment->getAdditionalInformation('payment_token');
$order = $payment->getOrder();
$response = $this->apiClient->authorize([
'amount' => (int) round($amount * 100),
'currency' => $order->getBaseCurrencyCode(),
'token' => $token,
'order_id' => $order->getIncrementId(),
]);
$payment->setTransactionId($response['transaction_id']);
$payment->setIsTransactionClosed(false);
$payment->setAdditionalInformation('auth_code', $response['auth_code']);
} catch (\Exception $e) {
$this->_logger->error('Authorize failed: ' . $e->getMessage());
throw new \Magento\Framework\Exception\LocalizedException(
__('Payment authorization failed. Please try again.')
);
}
return $this;
}
public function capture(\Magento\Payment\Model\InfoInterface $payment, $amount): static
{
try {
$authTxnId = $payment->getParentTransactionId();
$response = $this->apiClient->capture([
'auth_transaction_id' => $authTxnId,
'amount' => (int) round($amount * 100),
]);
$payment->setTransactionId($response['capture_id']);
$payment->setIsTransactionClosed(true);
} catch (\Exception $e) {
throw new \Magento\Framework\Exception\LocalizedException(
__('Payment capture failed.')
);
}
return $this;
}
public function refund(\Magento\Payment\Model\InfoInterface $payment, $amount): static
{
$captureTxnId = $payment->getParentTransactionId();
if (!$captureTxnId) {
throw new \Magento\Framework\Exception\LocalizedException(
__('Cannot refund without a capture transaction.')
);
}
$response = $this->apiClient->refund([
'capture_id' => $captureTxnId,
'amount' => (int) round($amount * 100),
]);
$payment->setTransactionId($response['refund_id'] . '-refund');
$payment->setIsTransactionClosed(true);
return $this;
}
}
Checkout JS renderer (Knockout)
// view/frontend/web/js/view/payment/method-renderer/vendor-payment.js
define([
'Magento_Checkout/js/view/payment/default',
'ko',
'mage/translate'
], function (Component, ko, $t) {
'use strict';
return Component.extend({
defaults: {
template: 'Vendor_Payment/payment/vendor-payment',
paymentToken: ko.observable(''),
},
initObservable: function () {
this._super().observe(['paymentToken']);
return this;
},
// Send token (never raw card data) to backend
getData: function () {
return {
method: this.item.method,
additional_data: {
payment_token: this.paymentToken()
}
};
},
// Called before order is placed - tokenise card via gateway SDK
beforePlaceOrder: function () {
// Example: Stripe.js tokenisation
const cardElement = this.getCardElement();
window.gatewaySDK.createToken(cardElement).then((token) => {
this.paymentToken(token.id);
this.placeOrder();
}).catch((error) => {
alert($t('Card tokenisation failed: ') + error.message);
});
},
validate: function () {
return !!this.paymentToken();
},
});
});
PCI DSS – key requirement
Never pass raw card numbers through Magento. The flow must be:
- Card data entered in the browser
- Gateway SDK tokenises directly from browser to gateway (card data never touches your server)
- Token sent to Magento backend
- Magento uses the token to call the gateway API
This approach keeps you in the simplest PCI DSS SAQ-A scope. Sending raw card data through Magento requires SAQ-D – a much heavier compliance burden.
Summary
A custom payment method touches more layers of Magento than almost any other integration. The PHP side is straightforward once you understand the authorize/capture/refund contract. The JS renderer handles the checkout UI. The critical security requirement is tokenisation – card data should never reach your server. Done correctly, the implementation is clean and the compliance scope is minimal.
