PHP / Magento Dev Blog

  • Home

Custom Payment Method in Magento 2 – authorize, capture, refund, webhook

by Henryk Tews / Wednesday, 24 June 2026 / Published in Magento / Adobe Commerce

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.

About Henryk Tews

What you can read next

Xdebug – configuration, PHPStorm, debugging Magento plugins
Strategy pattern in PHP – and how Magento 2 uses it in pricing

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}