Gateway Extensions

Build one-time and subscription payment gateways, handle webhooks and callbacks, and support refunds and currencies.

A gateway extension takes a WemX Payment (or Subscription), sends the customer to a payment provider, and marks the payment as paid when the provider confirms it. WemX takes care of everything else: invoices, tax, currency conversion, and running the payment's handler.

One-time payment gateway

extensions/Gateways/Acme/Gateway.php
<?php

namespace Extensions\Gateways\Acme;

use App\Extensions\Foundation\GatewayExtension;
use App\Models\GatewayConfig;
use App\Models\Payment;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class Gateway extends GatewayExtension
{
    protected string $id = 'gateway-acme';
    protected string $name = 'Acme Pay';
    protected string $description = 'Accept payments with Acme Pay.';
    protected string $type = 'Gateway';
    protected string $gatewayType = 'payment';
    protected string $version = '1.0.0';
    protected array $wemxVersions = ['*'];
    protected array $authors = [['name' => 'You', 'email' => 'you@example.com']];

    /** Currencies the provider accepts. Empty = any currency. */
    protected array $currencies = ['EUR', 'USD', 'GBP'];

    /** Default text shown to customers under the gateway name. */
    public string $gatewayDescription = 'Pay securely by card.';

    /** Fields for the gateway config form. Values are stored encrypted. */
    public static function setConfig(): array
    {
        return [
            'api_key' => [
                'label' => 'API Key',
                'description' => 'Your secret API key',
                'type' => 'text',
                'rules' => ['required', 'string'],
            ],
            'mode' => [
                'label' => 'Mode',
                'type' => 'select',
                'options' => ['test' => 'Test', 'live' => 'Live'],
                'rules' => ['required', 'in:test,live'],
            ],
        ];
    }

    /** Start the payment and redirect the customer to the provider. */
    public function pay(Payment $payment, GatewayConfig $gatewayConfig)
    {
        $checkout = Http::withToken($gatewayConfig->config('api_key'))
            ->post('https://api.acmepay.test/checkouts', [
                'amount' => (int) round($payment->total() * 100),
                'currency' => $payment->currency,
                'description' => $payment->description,
                'reference' => $payment->token,
                'return_url' => $payment->callbackUrl(),
                'cancel_url' => $payment->cancelUrl(),
                'webhook_url' => $payment->webhookUrl(),
            ])->throw()->json();

        $payment->update(['transaction_id' => $checkout['id']]);

        return redirect($checkout['url']);
    }

    /** The customer's browser returns here after paying. */
    public function callback(Request $request, GatewayConfig $gatewayConfig)
    {
        $payment = Payment::where('token', $request->payment_token)->firstOrFail();

        if (! $payment->isPaid() && $this->fetchStatus($payment, $gatewayConfig) === 'paid') {
            $payment->completed($payment->transaction_id);
        }

        return redirect($payment->successUrl());
    }

    /** Server-to-server notification from the provider. */
    public function webhook(Request $request, GatewayConfig $gatewayConfig)
    {
        $this->verifySignature($request, $gatewayConfig);   // throw if invalid

        $payment = Payment::where('transaction_id', $request->input('data.id'))->firstOrFail();

        if ($request->input('data.status') === 'paid') {
            $payment->completed($request->input('data.id'), $request->input('data'));
        }

        return response()->json(['success' => true]);
    }

    // fetchStatus() and verifySignature() omitted for brevity
}

Required methods

MethodPurpose
static setConfig(): arrayFields for the admin's gateway config form, keyed by config key. Each field has label, type (select, or an HTML input type such as text, password or number), rules, and optionally description, options, placeholder and default_value.
pay(Payment $payment, GatewayConfig $gatewayConfig)Start the payment and return a redirect, or render a view. Throw an exception to abort (keep secrets out of the message).

Read config values with $gatewayConfig->config('key', $default).

Webhooks and callbacks

If your class has a webhook() and/or callback() method, WemX routes these URLs to it:

URLMethodUse
/gateways/webhooks/{webhook_id}webhook(Request, GatewayConfig)Server-to-server notifications. Return a JSON response with HTTP 200 to acknowledge.
/gateways/callbacks/{webhook_id}callback(Request, GatewayConfig)Where the customer's browser lands after paying. Return a redirect.

{webhook_id} identifies the gateway config, so one installation can run several configs of your gateway. Build per-payment URLs with $payment->webhookUrl() and $payment->callbackUrl(). Both add a payment_token query parameter.

Every webhook request is logged (headers and payload). If your method throws, the error is logged too and the provider gets an HTTP 500 response, so it retries.

Webhook and callback routes are CSRF-exempt and public. Always verify the request: check the provider's signature (see the Stripe Checkout gateway's verifyStripeWebhook()), or re-fetch the payment status from the provider's API rather than trusting the request body.

Marking payments as paid

$payment->completed($transactionId, $rawProviderData);

completed() is idempotent: it does nothing if the payment is already paid, so it's safe when the webhook and the callback both arrive. It:

  1. sets the status to paid and stores the transaction ID, provider data and paid_at,
  2. saves the tax details captured at checkout,
  3. runs the payment's handler (provisioning orders, extending a renewal, crediting a balance…),
  4. fires App\Events\Payments\PaymentCompleted and emails the customer a receipt.

Useful Payment methods: total(), isPaid(), successUrl(), cancelUrl(), webhookUrl(), callbackUrl(), logPaymentWebhook($message) and data($key).

Currencies

Declare supported currencies in $currencies (or with a currencies() method). Before pay() is called, WemX converts the payment:

  • to the customer's selected currency, if you support it, or
  • to your gateway's base currency (the store's base currency if you support it, otherwise the first one in your list).

So $payment->currency and $payment->total() are always values your provider accepts.

Refunds

Add a refund() method to make Refund available on paid payments that used your gateway:

// Full refunds only
public function refund(Payment $payment) { /* call the provider */ }

// Partial refunds: WemX detects the $amount parameter by name
public function refund(Payment $payment, $amount) { /* call the provider */ }

Throw an exception if the provider rejects the refund. The message is shown to the admin.

Subscription gateways

Extend SubscriptionGatewayExtension (and set $gatewayType = 'subscription') to support recurring billing. Implement three methods:

use App\Extensions\Foundation\SubscriptionGatewayExtension;
use App\Models\GatewayConfig;
use App\Models\Subscription;

class Gateway extends SubscriptionGatewayExtension
{
    protected string $gatewayType = 'subscription';

    public function subscribe(Subscription $subscription, GatewayConfig $gatewayConfig)
    {
        // Create a plan/agreement at the provider for $subscription->amount
        // every $subscription->frequency days, in $subscription->currency,
        // then redirect the customer to approve it.
    }

    public function checkSubscription(Subscription $subscription, GatewayConfig $gatewayConfig)
    {
        // Sync status from the provider; update the next billing date.
    }

    public function cancelSubscription(Subscription $subscription, GatewayConfig $gatewayConfig)
    {
        // Cancel at the provider, then:
        $subscription->cancelled();
    }
}

Report state changes from your webhook handler with these Subscription methods. Each one updates the subscription, emails the customer where relevant, and calls the matching method on the subscription's handler (onSubscriptionActivated, onSubscriptionBillingDateUpdated, onSubscriptionDeactivated, onSubscriptionCancelled), which keeps the linked order's due date in sync:

MethodWhen to call it
$subscription->activated($providerId, $nextBillingAt, $data)The customer approved the agreement
$subscription->updateNextBillingDate($date)A recurring payment succeeded
$subscription->inactive()Payments stopped (failed, suspended)
$subscription->cancelled($reason)The agreement was cancelled

See extensions/Gateways/PayPalSubscription for a complete, production-style implementation, and SandboxSubscription for the simplest one.

Testing

  1. php artisan extension:discover, then enable the gateway under Admin → Gateways → Installed Gateways.
  2. Create a gateway config with your provider's test credentials, and mark it Staff only.
  3. Check out as an admin, then confirm the payment turns paid and the order is created.
  4. To test webhooks locally, expose your site with a tunnel (for example ngrok or expose) and register the tunnel URL with the provider.

On this page