Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/own-pay/OwnPay-Documentation/llms.txt

Use this file to discover all available pages before exploring further.

Gateway plugins add new payment methods to OwnPay — from global processors like Stripe and PayPal to regional options like bKash, GCash, or SEPA Direct Debit, and even cryptocurrency processors. When you build a gateway plugin, OwnPay’s GatewayBridge handles credential decryption, routes every payment flow to your adapter methods, and integrates your gateway into the standard checkout UI automatically.

How Gateway Plugins Work

When OwnPay boots, PluginLoader::loadActive() scans modules/gateways/, validates each manifest.json, token-scans every PHP file for dangerous functions, and instantiates the entrypoint class. Because a gateway class implements both PluginInterface and GatewayAdapterInterface, the loader automatically calls GatewayBridge::registerAdapter($instance) after the boot phase. From that point, every payment flow is routed through GatewayBridge:
HTTP Request
    └─ CheckoutController / WebhookController
         └─ GatewayBridge::initiate() / verify() / refund() / verifyWebhookSignature()
              └─ GatewayAdapterInterface (your plugin instance)
Credentials are never passed raw. GatewayBridge calls GatewayConfigRepository::findCredentialsBySlug() for the active merchant, decrypts the stored JSON blob with FieldEncryptor, and passes the resulting plain-text key-value map to your adapter methods as the $credentials array.

Directory Structure

modules/gateways/my-gateway/
├── manifest.json           # Required. Plugin metadata and capabilities.
├── MyGateway.php           # Required. Implements PluginInterface + GatewayAdapterInterface.
├── icon.svg                # Recommended. Shown in the admin gateway list.
├── assets/
│   └── checkout.js         # Optional. Loaded on the checkout page.
└── migrations/
    └── 001_create_my_gateway_logs.sql
The slug must be lowercase, use only letters, numbers, and hyphens, and match the directory name exactly.

The manifest.json File

{
  "name": "My Gateway",
  "slug": "my-gateway",
  "version": "1.0.0",
  "description": "Accept payments via My Gateway — cards and bank transfers.",
  "author": "Your Name",
  "type": "gateway",
  "entrypoint": "MyGateway.php",
  "namespace": "OwnPay\\Modules\\Gateways\\MyGateway",
  "capabilities": ["gateway"],
  "requires": {
    "core": ">=0.1.0",
    "php": ">=8.1"
  },
  "icon": "icon.svg",
  "color": "#0D9488",
  "category": "global",
  "csp": {
    "script_src": ["https://js.mygateway.com"],
    "connect_src": ["https://api.mygateway.com"]
  },
  "permissions": [
    "gateway.process",
    "gateway.refund"
  ]
}
FieldRequiredDescription
nameDisplay name in the admin panel.
slugUnique lowercase identifier matching directory name. Pattern: ^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$.
versionSemantic version (e.g., 1.0.0).
descriptionShort description.
authorAuthor name or organisation.
typeMust be "gateway".
entrypointPHP filename containing the entrypoint class.
namespacePSR-4 root namespace. Entrypoint resolved as {namespace}\{EntrypointClass}.
capabilitiesMust include "gateway".
requires.coreSemver constraint on OwnPay core version.
categoryUI grouping: "global", "local", or "crypto".
cspCSP directive extensions for any external JS SDK.
permissionsRBAC keys required. Gateways should declare "gateway.process".
iconIcon filename. Copied to public/assets/img/gateways/{slug}.{ext}.
colorHex brand color (e.g., "#635BFF").

The Entrypoint Class

A gateway plugin’s entrypoint must implement both OwnPay\Plugin\PluginInterface and OwnPay\Gateway\GatewayAdapterInterface. The GatewayDefaults trait provides safe, no-op fallbacks for every method — use it and override only what your gateway supports.
<?php
declare(strict_types=1);

namespace OwnPay\Modules\Gateways\MyGateway;

use OwnPay\Container;
use OwnPay\Event\EventManager;
use OwnPay\Gateway\GatewayAdapterInterface;
use OwnPay\Gateway\GatewayDefaults;
use OwnPay\Plugin\Capability;
use OwnPay\Plugin\PluginInterface;

final class MyGateway implements PluginInterface, GatewayAdapterInterface
{
    use GatewayDefaults;

    // ─── PluginInterface ────────────────────────────────────────────────────

    public static function metadata(): array
    {
        return [
            'name'        => 'My Gateway',
            'slug'        => 'my-gateway',
            'version'     => '1.0.0',
            'description' => 'My custom payment gateway.',
            'author'      => 'Your Name',
            'type'        => 'gateway',
        ];
    }

    public function capabilities(): array
    {
        return [Capability::GATEWAY];
    }

    public function register(EventManager $events, Container $container): void
    {
        // Register event hooks here if needed.
        // GatewayBridge registration is handled automatically.
    }

    public function boot(Container $container): void {}

    public function deactivate(Container $container): void {}

    public function uninstall(Container $container): void
    {
        // Drop op_plugin_my_gateway_* tables here.
    }

    public function fields(): array
    {
        return [
            [
                'name'     => 'api_key',
                'label'    => 'API Key',
                'type'     => 'password',
                'required' => true,
            ],
            [
                'name'     => 'mode',
                'label'    => 'Mode',
                'type'     => 'select',
                'options'  => ['sandbox' => 'Sandbox', 'live' => 'Live'],
                'required' => true,
            ],
        ];
    }

    // ─── GatewayAdapterInterface ─────────────────────────────────────────────

    public function slug(): string
    {
        return 'my-gateway';
    }

    public function initiate(array $params, array $credentials): array
    {
        // See: Processing Payments section below
    }

    public function verify(array $callbackData, array $credentials): array
    {
        // See: Handling Callbacks section below
    }

    public function verifyWebhook(string $rawBody, array $headers, array $credentials): bool
    {
        // See: Webhook Verification section below
    }

    public function refund(string $gatewayTrxId, string $amount, array $credentials): array
    {
        // See: Refund Logic section below
    }

    public function supports(string $feature): bool
    {
        return match ($feature) {
            'refund', 'verification' => true,
            default                  => false,
        };
    }

    public function supportedCurrencies(): array
    {
        return []; // Empty = accepts any currency
    }
}

Processing Payments — initiate()

initiate() is called by GatewayBridge::initiate() to start a payment session. The $params array always has this shape:
[
    'amount'       => '150.00',           // BCMath-safe decimal string
    'currency'     => 'USD',              // ISO 4217
    'trx_id'       => 'OP-20240701-ABCD', // OwnPay internal transaction ID
    'redirect_url' => 'https://pay.example.com/checkout/callback?session=...',
    'cancel_url'   => 'https://pay.example.com/checkout/cancel',
    'metadata'     => [],
]
Never cast $params['amount'] to float. Floating-point arithmetic corrupts large values. Use the toMinorUnits() helper from GatewayDefaults for gateways that require integer cents, or toDecimalString() for those that accept decimal strings.
Your method must return one of three shapes:
public function initiate(array $params, array $credentials): array
{
    $apiKey = $credentials['api_key'] ?? '';
    $mode   = $credentials['mode']    ?? 'sandbox';

    // Convert to minor units (cents) for APIs that require integers
    $amountCents = $this->toMinorUnits($params['amount']); // '150.00' → 15000

    // Call the gateway API to create a payment session
    $response = $this->callGatewayApi('/sessions', $apiKey, [
        'amount'       => $amountCents,
        'currency'     => strtolower($params['currency']),
        'reference'    => $params['trx_id'],
        'return_url'   => $params['redirect_url'],
        'cancel_url'   => $params['cancel_url'],
    ]);

    // Option 1 — Redirect to hosted payment page
    return ['redirect_url' => $response['checkout_url']];

    // Option 2 — Render a self-hosted HTML form
    // return ['form_html' => '<form method="POST" action="https://gateway.example.com/pay">...</form>'];

    // Option 3 — Return a session ID for JS SDK-based checkout
    // return ['session_id' => $response['id']];
}

Handling Callbacks — verify()

verify() confirms a payment after the user returns from the gateway. The gateway’s own API is the only source of truth.
public function verify(array $callbackData, array $credentials): array
{
    $sessionId = (string) ($callbackData['session_id'] ?? '');
    if ($sessionId === '') {
        return ['success' => false, 'gateway_trx_id' => '', 'status' => 'failed'];
    }

    // ALWAYS make a server-side API call — never trust $callbackData's status field
    $response = $this->callGatewayApi(
        '/sessions/' . urlencode($sessionId),
        $credentials['api_key'] ?? ''
    );

    $paid = ($response['status'] ?? '') === 'paid';

    return [
        'success'        => $paid,
        'gateway_trx_id' => (string) ($response['id'] ?? ''),
        'amount'         => $paid ? (string) ($response['amount_decimal'] ?? null) : null,
        'status'         => $paid ? 'completed' : 'failed',
    ];
}
Security Rule: Never trust the payment status from $callbackData. A forged callback can manipulate the status field. Always make a server-to-server API call to verify the true payment status.

Webhook Verification — verifyWebhook()

verifyWebhook() is called by GatewayBridge::verifyWebhookSignature() before the webhook payload is processed. The Stripe HMAC-SHA256 pattern — the reference implementation — demonstrates proper signature checking with replay protection:
public function verifyWebhook(string $rawBody, array $headers, array $credentials): bool
{
    $secret = $credentials['webhook_secret'] ?? '';
    if ($secret === '') {
        // Fail closed — no secret means reject all webhooks
        return false;
    }

    $sigHeader = $headers['Stripe-Signature'] ?? $headers['stripe-signature'] ?? '';
    if ($sigHeader === '') {
        return false;
    }

    // Parse "t=timestamp,v1=signature"
    $parts = [];
    foreach (explode(',', $sigHeader) as $item) {
        $kv = explode('=', $item, 2);
        if (count($kv) === 2) {
            $parts[trim($kv[0])] = trim($kv[1]);
        }
    }

    $timestamp   = $parts['t']  ?? '';
    $expectedSig = $parts['v1'] ?? '';

    if ($timestamp === '' || $expectedSig === '') {
        return false;
    }

    // Replay protection — reject if older than 5 minutes
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $computed = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    // Always use hash_equals() — never === — for timing-safe comparison
    return hash_equals($computed, $expectedSig);
}
The gateway.webhook.{slug} action hook fires after verifyWebhook() returns true. Your gateway can also listen to this hook inside register() to process the verified payload.

Refund Logic — refund()

$gatewayTrxId is the gateway_trx_id your verify() method returned. $amount is a BCMath-safe decimal string.
public function refund(string $gatewayTrxId, string $amount, array $credentials): array
{
    $amountCents = $this->toMinorUnits($amount);

    $response = $this->callGatewayApi('/refunds', $credentials['api_key'] ?? '', [
        'payment_id' => $gatewayTrxId,
        'amount'     => $amountCents,
    ]);

    if (($response['status'] ?? '') === 'succeeded') {
        return [
            'success'   => true,
            'refund_id' => (string) ($response['id'] ?? ''),
            'error'     => null,
        ];
    }

    return [
        'success'   => false,
        'refund_id' => null,
        'error'     => (string) ($response['error']['message'] ?? 'Refund failed'),
    ];
}
If your gateway does not support refunds, override supports('refund') to return false. OwnPay uses the supports() return value to determine whether to show the refund button in the transaction UI.

Test Mode Implementation

Use the mode field (declared in fields()) to switch between sandbox and live environments. The $credentials array contains the decrypted values your merchant configured.
public function initiate(array $params, array $credentials): array
{
    $apiKey  = $credentials['api_key'] ?? '';
    $mode    = $credentials['mode']    ?? 'sandbox';
    $baseUrl = $mode === 'live'
        ? 'https://api.mygateway.com/v1'
        : 'https://sandbox.mygateway.com/v1';

    // Use $baseUrl for all API calls in this method
}
Always provide a "sandbox" option in your fields() mode select. Operators need to test integrations without processing real payments.

Database Migrations

Plugin tables must be prefixed with op_plugin_. Direct queries to any core op_* table are blocked by PluginSandbox::validateSql().
-- migrations/001_create_my_gateway_logs.sql
CREATE TABLE IF NOT EXISTS op_plugin_my_gateway_logs (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    merchant_id INT UNSIGNED    NOT NULL,
    trx_id      VARCHAR(128)    NOT NULL,
    request     JSON            DEFAULT NULL,
    response    JSON            DEFAULT NULL,
    created_at  DATETIME(6)     NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_merchant_trx (merchant_id, trx_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Hooks Available to Gateway Plugins

Register hooks inside register() to react to payment events or modify checkout behavior:
public function register(EventManager $events, Container $container): void
{
    // Log every payment attempt through this gateway
    $events->addAction('gateway.capture.after', function (string $slug, array $result) use ($container): void {
        if ($slug !== 'my-gateway') {
            return;
        }
        // Log to op_plugin_my_gateway_logs...
    }, priority: 10);

    // Inject the gateway JS SDK into the checkout head
    $events->addAction('checkout.head', function (): void {
        echo '<script src="https://js.mygateway.com/v3"></script>';
    });
}
HookTypeUse Case
gateway.capture.beforeFilterModify $params (amount, metadata) before initiate().
gateway.capture.afterActionLog the payment attempt.
payment.transaction.completedActionPost-payment notifications.
payment.transaction.failedActionAlert the merchant on decline.
checkout.headActionInject gateway JS SDK.
checkout.footerActionInject SDK init scripts.
checkout.csp.sourcesFilterWhitelist gateway domains in the CSP header.

Security Requirements

RuleDetail
Never trust callback dataAlways verify payment status server-side via the gateway API.
Use hash_equals()All HMAC and signature comparisons must use timing-safe comparison.
Replay protectionReject webhook timestamps older than 5 minutes.
Fail closedIf webhook_secret is missing, verifyWebhook() must return false.
No eval()PluginLoader token-scans every PHP file. eval() causes load failure.
No OS commandsexec(), shell_exec(), system(), passthru(), popen(), proc_open(), pcntl_exec(), dl() are blocked.
op_plugin_ tables onlyDirect queries to core OwnPay tables are blocked at SQL level.

Installation Checklist

  • manifest.json present with all required fields; slug matches directory name.
  • namespace declared; entrypoint class in the correct PSR-4 path.
  • Entrypoint implements both PluginInterface and GatewayAdapterInterface.
  • slug() returns the exact value declared in manifest.json.
  • initiate() returns a valid response shape (redirect_url, form_html, or session_id).
  • verify() makes a server-side API call — never trusts callback data alone.
  • verifyWebhook() uses hash_equals() and enforces a 5-minute timestamp replay window.
  • refund() handles the success: false case gracefully.
  • supports() accurately reflects what the gateway implements.
  • Plugin tables use the op_plugin_ prefix.
  • No use of eval(), exec(), shell_exec(), or equivalent functions.
  • CSP domains declared in manifest.json for any external JS SDK.
  • icon.svg provided for display in the admin panel.

Build docs developers (and LLMs) love