Payment Module Development

Payment modules extend ModuleBase, appear at checkout, and optionally redirect to a PSP before the order is created.

Core properties

PropertyPurpose
$isPayment = trueRegisters as a checkout payment method
$paymentMethodIdStored in orders.payment_method (must match radio value)
$paymentMethodLabelAdmin / checkout label
$paysBeforeOrdertrue for virtual POS — pay before Order::placePending()

Display hooks

  • order_payment — radio / UI on checkout (required for selection)
  • order_confirmation — post-order instructions (e.g. bankwire IBAN)
  • For paysBeforeOrder, also define getPaymentPageUrl() and a $routes front page

Full list: Hook List. Base guide: Create New Module.

processPayment(array $order)

Called after the order is saved for post-order methods (bank transfer, COD-style). Return ['success' => bool, 'redirect' => string, 'message' => string]. A non-empty redirect sends the customer to that URL.

Callbacks & CSRF exemptions

Register PSP callbacks in $apiActions, e.g. 'callback' => 'api/callback.php', and expose them via /api/module.php?m={name}&action=callback.

CSRF-exempt module actions (bank POST back): callback, notify, webhook, ipn, return, 3d-return / 3dreturn. Still verify signatures / hashes from the PSP — exemption only skips session CSRF.

Reference modules

  • bankwire — post-order bank transfer + confirmation hook
  • sanalpospaysBeforeOrder + front payment route
  • PayTR / ParamPOS-style modules — token + iframe / 3D Secure + callback verify
Security: Never trust amounts or status from the browser. Verify PSP signatures on every callback; mark paid only after a successful verify. Stay PHP 7.4 compatible (no match, named arguments, or union types).
PHP
<?php

if (!defined('IN_SCRIPT') && !defined('IN_ADMIN')) {
	exit;
}

class DemoPayModule extends ModuleBase
{
	public string $name = 'demo-pay';
	public string $version = '1.0.0';
	public string $displayName = 'Demo Pay';
	public string $description = 'Sample payment module';

	public bool $isPayment = true;
	public string $paymentMethodId = 'demo_pay';
	public string $paymentMethodLabel = 'Demo Pay';
	public bool $paysBeforeOrder = false;

	public array $displayHooks = [
		'order_payment' => 'Checkout payment option',
		'order_confirmation' => 'Thank-you page instructions',
	];

	public array $defaultDisplayHooks = [
		'order_payment',
		'order_confirmation',
	];

	public array $apiActions = [
		'callback' => 'api/callback.php',
	];

	public function processPayment(array $order): array
	{
		// Post-order flow (bankwire-style). Redirect only if needed.
		return [
			'success' => true,
			'redirect' => '',
			'message' => '',
		];
	}

	public function renderDisplayHook(string $hook, array $context = [])
	{
		if ($hook === 'order_payment') {
			return $this->renderFrontTemplate('order_payment', [
				'paymentMethodId' => $this->paymentMethodId,
				'label' => $this->paymentMethodLabel,
				'formData' => isset($context['formData']) ? $context['formData'] : [],
			]);
		}

		if ($hook === 'order_confirmation') {
			return $this->renderFrontTemplate('order_confirmation', [
				'order' => isset($context['order']) ? $context['order'] : [],
			]);
		}

		return null;
	}
}
callback.php — signature verify sketch
<?php
// modules/demo-pay/api/callback.php — verify PSP signature sketch

if (!defined('IN_SCRIPT')) {
	exit;
}

header('Content-Type: application/json; charset=utf-8');

$merchantKey = (string) Settings::get('DEMO_PAY_MERCHANT_KEY');
$payload = file_get_contents('php://input');
$data = json_decode((string) $payload, true);
if (!is_array($data)) {
	$data = $_POST;
}

$provided = isset($data['hash']) ? (string) $data['hash'] : '';
$orderId = isset($data['merchant_oid']) ? (string) $data['merchant_oid'] : '';
$status = isset($data['status']) ? (string) $data['status'] : '';
$total = isset($data['total_amount']) ? (string) $data['total_amount'] : '';

$expected = base64_encode(hash_hmac(
	'sha256',
	$orderId . $merchantKey . $status . $total,
	$merchantKey,
	true
));

if ($provided === '' || !hash_equals($expected, $provided)) {
	http_response_code(403);
	echo json_encode(['success' => false, 'message' => 'Invalid signature']);
	exit;
}

// Never trust client amount alone — load order and compare server-side totals here.
if ($status !== 'success') {
	echo json_encode(['success' => false, 'message' => 'Payment not successful']);
	exit;
}

echo json_encode(['success' => true]);