A custom payment method in Magento 2 is one of the more complex modules to write from scratch. It requires implementing the MethodInterface, configuration in system.xml, JS templates for checkout, and handling return webhooks from the gateway. I show the complete module structure integrating a fictional payment gateway – from authorisation through capture to refund handling.
Module structure
Vendor/Payment/
etc/
config.xml - default configuration values
module.xml
adminhtml/
system.xml - configuration in Admin > Stores > Config
frontend/
di.xml - register JS checkout components
Model/
Adapter.php - HTTP client for the gateway API
Payment.php - main payment method class
Response/
Handler.php - handles gateway responses
Controller/
Webhook/
Index.php - endpoint for webhooks (IPN)
view/
frontend/
web/js/
view/
payment/
method-renderer.js - Knockout component in checkout
layout/
checkout_index_index.xml
Payment method class
<?php
declare(strict_types=1);
namespace Vendor\Payment\Model;
use Magento\Payment\Model\Method\AbstractMethod;
use Magento\Framework\Exception\LocalizedException;
class Payment extends AbstractMethod
{
protected $_code = 'vendor_payment';
protected $_isGateway = true;
protected $_canCapture = true;
protected $_canCapturePartial = false;
protected $_canRefund = true;
protected $_canRefundInvoicePartial = true;
protected $_canVoid = true;
protected $_canAuthorize = true;
public function __construct(
private Adapter $adapter,
\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,
array $data = []
) {
parent::__construct(
$context, $registry, $extensionFactory, $customAttributeFactory,
$paymentData, $scopeConfig, $logger, null, null, $data
);
}
public function authorize(
\Magento\Payment\Model\InfoInterface $payment,
$amount
): static {
$order = $payment->getOrder();
$response = $this->adapter->authorize([
'amount' => $amount,
'currency' => $order->getOrderCurrencyCode(),
'order_id' => $order->getIncrementId(),
'customer' => [
'email' => $order->getCustomerEmail(),
'name' => $order->getCustomerFirstname() . ' ' . $order->getCustomerLastname(),
],
'return_url' => $this->getConfigData('return_url'),
'webhook_url' => $this->getConfigData('webhook_url'),
]);
if (!$response->isSuccess()) {
throw new LocalizedException(__('Payment authorization failed: %1', $response->getMessage()));
}
$payment->setTransactionId($response->getTransactionId());
$payment->setAdditionalInformation('authorization_id', $response->getAuthorizationId());
$payment->setIsTransactionClosed(false);
return $this;
}
public function capture(
\Magento\Payment\Model\InfoInterface $payment,
$amount
): static {
$authId = $payment->getAdditionalInformation('authorization_id');
if (empty($authId)) {
return $this->directCharge($payment, $amount);
}
$response = $this->adapter->capture([
'authorization_id' => $authId,
'amount' => $amount,
]);
if (!$response->isSuccess()) {
throw new LocalizedException(__('Payment capture failed: %1', $response->getMessage()));
}
$payment->setTransactionId($response->getTransactionId());
$payment->setAdditionalInformation('capture_id', $response->getCaptureId());
$payment->setIsTransactionClosed(true);
return $this;
}
public function refund(
\Magento\Payment\Model\InfoInterface $payment,
$amount
): static {
$captureId = $payment->getAdditionalInformation('capture_id')
?? $payment->getParentTransactionId();
if (empty($captureId)) {
throw new LocalizedException(__('Cannot refund: no capture transaction found'));
}
$response = $this->adapter->refund([
'capture_id' => $captureId,
'amount' => $amount,
'reason' => 'Customer refund request',
]);
if (!$response->isSuccess()) {
throw new LocalizedException(__('Refund failed: %1', $response->getMessage()));
}
$payment->setTransactionId($response->getRefundTransactionId());
$payment->setIsTransactionClosed(true);
return $this;
}
private function directCharge(
\Magento\Payment\Model\InfoInterface $payment,
float $amount
): static {
$order = $payment->getOrder();
$response = $this->adapter->charge([
'amount' => $amount,
'currency' => $order->getOrderCurrencyCode(),
'order_id' => $order->getIncrementId(),
'token' => $payment->getAdditionalInformation('payment_token'),
]);
if (!$response->isSuccess()) {
throw new LocalizedException(__('Payment failed: %1', $response->getMessage()));
}
$payment->setTransactionId($response->getTransactionId());
$payment->setAdditionalInformation('capture_id', $response->getCaptureId());
return $this;
}
}
HTTP Adapter for the gateway
<?php
declare(strict_types=1);
namespace Vendor\Payment\Model;
use Magento\Framework\HTTP\Client\Curl;
class Adapter
{
private string $apiUrl;
private string $apiKey;
public function __construct(
private Curl $curl,
private \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
private \Psr\Log\LoggerInterface $logger
) {
$this->apiUrl = $this->scopeConfig->getValue('payment/vendor_payment/api_url');
$this->apiKey = $this->scopeConfig->getValue('payment/vendor_payment/api_key');
}
public function authorize(array $data): Response\ApiResponse { return $this->request('POST', '/v1/authorizations', $data); }
public function capture(array $data): Response\ApiResponse { return $this->request('POST', '/v1/captures', $data); }
public function charge(array $data): Response\ApiResponse { return $this->request('POST', '/v1/charges', $data); }
public function refund(array $data): Response\ApiResponse { return $this->request('POST', '/v1/refunds', $data); }
private function request(string $method, string $endpoint, array $data): Response\ApiResponse
{
$url = $this->apiUrl . $endpoint;
$this->curl->addHeader('Content-Type', 'application/json');
$this->curl->addHeader('Authorization', 'Bearer ' . $this->apiKey);
$this->curl->addHeader('X-Idempotency-Key', $this->generateIdempotencyKey($data));
$this->logger->debug('Payment API request', ['url' => $url, 'data' => $data]);
try {
if ($method === 'POST') {
$this->curl->post($url, json_encode($data));
}
$responseBody = $this->curl->getBody();
$statusCode = $this->curl->getStatus();
$decoded = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
return new Response\ApiResponse($statusCode, $decoded);
} catch (\Exception $e) {
$this->logger->error('Payment API error', ['exception' => $e->getMessage()]);
throw new \Magento\Framework\Exception\LocalizedException(
__('Payment gateway connection error')
);
}
}
private function generateIdempotencyKey(array $data): string
{
return hash('sha256', json_encode($data) . microtime());
}
}
Webhook controller – IPN handling
<?php
declare(strict_types=1);
namespace Vendor\Payment\Controller\Webhook;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
class Index implements HttpPostActionInterface, CsrfAwareActionInterface
{
public function __construct(
private \Magento\Framework\App\RequestInterface $request,
private \Magento\Framework\Controller\Result\JsonFactory $jsonFactory,
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteriaBuilder,
private \Psr\Log\LoggerInterface $logger,
) {}
public function execute(): \Magento\Framework\Controller\Result\Json
{
$result = $this->jsonFactory->create();
$payload = json_decode($this->request->getContent(), true);
if (!$this->verifySignature($payload)) {
return $result->setHttpResponseCode(401)->setData(['error' => 'Invalid signature']);
}
try {
$this->processEvent($payload);
return $result->setData(['status' => 'ok']);
} catch (\Exception $e) {
$this->logger->error('Webhook processing failed', ['exception' => $e->getMessage()]);
return $result->setHttpResponseCode(500)->setData(['error' => $e->getMessage()]);
}
}
private function processEvent(array $payload): void
{
$eventType = $payload['event_type'] ?? '';
$orderId = $payload['metadata']['order_id'] ?? '';
$sc = $this->searchCriteriaBuilder->addFilter('increment_id', $orderId)->create();
$orders = $this->orderRepository->getList($sc)->getItems();
$order = reset($orders);
if (!$order) {
throw new \RuntimeException("Order not found: {$orderId}");
}
match($eventType) {
'payment.authorized' => $this->handleAuthorized($order, $payload),
'payment.captured' => $this->handleCaptured($order, $payload),
'payment.failed' => $this->handleFailed($order, $payload),
'payment.refunded' => $this->handleRefunded($order, $payload),
default => $this->logger->info("Unknown event: {$eventType}"),
};
}
private function verifySignature(array $payload): bool
{
$signature = $this->request->getHeader('X-Webhook-Signature') ?? '';
$secret = $this->getWebhookSecret();
$computed = hash_hmac('sha256', json_encode($payload), $secret);
return hash_equals($computed, $signature); // never use ==
}
public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException { return null; }
public function validateForCsrf(RequestInterface $request): ?bool { return true; }
private function getWebhookSecret(): string { return 'webhook_secret'; }
}
config.xml – default configuration
<?xml version="1.0"?>
<config>
<default>
<payment>
<vendor_payment>
<active>0</active>
<model>Vendor\Payment\Model\Payment</model>
<title>Pay with VendorPay</title>
<payment_action>authorize_capture</payment_action>
<api_url>https://api.vendorpay.com</api_url>
<order_status>pending</order_status>
<allowspecific>0</allowspecific>
<sort_order>10</sort_order>
</vendor_payment>
</payment>
</default>
</config>
Summary
A custom payment method in Magento 2 consists of: a class implementing AbstractMethod (authorize, capture, refund), an HTTP Adapter for the gateway API, a Webhook controller for handling notifications, and XML configuration. Key points: transactionId must be saved in payment postmeta, CSRF must be disabled for the webhook, always verify the webhook signature with hash_equals (never ==). A complete implementation also requires a JS component in checkout – that is a topic for a separate post.
