Skip to content

Payment Gateways ​

We use the following off-site gateways. The following gateways can be enabled and configured in the control panel and after that you can use the following tag (inside your checkout tag) to loop over your enabled gateways.

gateway_list tag ​

Below an example to generate a list of gateway providers in a select list

{exp:reinos_store:checkout}
    {!-- This is a select list, but your can convert it to anything you like. As long we have an input[gateway] field in the checkout tag  --}
    <select name="gateway">
        <option value="">Choose your gateway</option>
        {exp:reinos_store:gateway_list}
            <option id="{gateway:id}" value="{gateway:name}">{gateway:label}</option>
        {/exp:reinos_store:gateway_list}
    </select>
{/exp:reinos_store:checkout}

The tag lists the enabled gateways, plus the payment options that add-ons add (e.g. "pay at the location" of Store Pickup for a pickup order, also when no gateway is enabled). An option of an add-on with the same name as an enabled gateway replaces that gateway. Without any option, the {if no_results} part of the tag is shown.

Variables (with the default prefix gateway:, change it with the prefix param):

  • {gateway:id}: the ID of the gateway
  • {gateway:name}: the name of the gateway, post it as the gateway field
  • {gateway:label}: the label of the gateway, as set in the CP
  • {gateway:is_selected}: whether this gateway is the one chosen in the cart
{exp:reinos_store:gateway_list}
    <label><input type="radio" name="gateway" value="{gateway:name}" {if gateway:is_selected}checked{/if}> {gateway:label}</label>
{/exp:reinos_store:gateway_list}

However, it is also possible to just enable one gateway by providing an hidden input

<input name="gateway" type="hidden" value="manual">

Template parameter ​

Alternative it is also possible to enable just one payment gateway via the template parameter payment_gateway in your {exp:reinos_store:checkout} tag.

{exp:reinos_store:checkout payment_gateway="manual"}

Manual ​

By using this Gateway you can order any product without doing a real payment. Mostly this is used to send the customer a custom invoice. But it can also be used when developing your site where you cannot use the real provider.

payment_gateway="manual"

Status of a new order ​

Available from v4.0.0

Choose the status a new order with this gateway gets: Pending (default) or Complete. Either way the order stays unpaid until you set it to paid in the CP. Before v4.0.0 a manual order stayed open, the status of an unfinished checkout; existing orders keep their status.

A paid manual order gets the Refund button in the CP. There is no provider, so it only marks the order as refunded: pay the money back yourself.

Mollie ​

This is a Dutch payment provider that support a large amount of payment methods. The webhook (notify) URL is sent with every payment, you don't have to set it in the Mollie dashboard. A paid order can be refunded from the CP.

Usage ​

Go to your dashboard --> Developers --> API Keys. There you see an API key for either testing or for production. Both API keys are needed, so you can copy both.

payment_gateway="mollie"

MultiSafepay ​

This is another Dutch payment provider that support a large amount of payment methods. The notification URL is sent with every payment, you don't have to set it in the MultiSafepay dashboard. A paid order can be refunded from the CP.

Usage ​

Go to your dashboard --> Settings --> Website --> API Keys. By default you have a production account, but you can also create a developer account via https://testmerchant.multisafepay.com to obtain a test API key

payment_gateway="multiSafepay"

Statuses ​

  • A payment that is still being checked (uncleared or reserved) makes the order pending, it keeps its stock.
  • A declined payment leaves the order as it is, so the customer can try again.
  • A cancelled or void payment makes the order cancelled, an expired payment expired.
  • A refunded or charged back order gets the status refunded or paid_chargeback and stays paid, like with the other gateways. Orders refunded before v4.0.0 keep their status.

PayPal ​

For PayPal we use the REST client to give the best PayPal experience. PayPal has no webhook in the Store: the order is completed when the customer confirms it on the review page, and the amount and currency are checked with PayPal before the payment is executed. A paid order can be refunded from the CP, with the sale id of the payment.

Usage ​

  • Go to developer.paypal.com and login with your own credentials.
  • Navigate to "My Apps and Credentials". By default, there is an app created for the sandbox (test) version but for the Live version you have to create a new app.
  • When your app has been created, you can copy the Client ID and the Secret to the Store PayPal fields.
payment_gateway="paypal"

PayPal uses a review step: after approving the payment at PayPal, the customer returns to the review page (see review_template) and confirms the order there. The order is checked with PayPal on the return and again on the review page. When PayPal can't be reached or gives a temporary error, the order stays as it is and the customer sees a message to reload the page. Only a payment that PayPal no longer knows, or with the wrong amount or currency (e.g. when the order total was changed in the CP), expires the order. Available from v4.0.0

Products are sent to PayPal one line per product. PayPal doesn't accept a negative discount line and refuses a list that doesn't add up to the total, so when the products don't add up (e.g. with a discount) they go in one line with the amount to pay, like Princess Boat x 2, includes 75.00 USD discount. Available from v4.0.0

PayPal doesn't support decimals for JPY, HUF and TWD, the amount is rounded to a whole amount for these currencies. Turn off show decimals in the settings, so the customer sees the same amount. Available from v4.0.0

Stripe (Checkout) ​

We support Stripe checkout. See the official documentation https://stripe.com/docs/payments/checkout

payment_gateway="stripe"

In the CP this gateway is called Stripe.

Usage ​

  • Go to developer --> API Keys
  • Copy the Publishable and the Secret key and paste them in the payment overview for Stripe
  • Available payment methods: the methods offered on the Stripe page (card when none is selected). Only methods that are activated in your Stripe dashboard are shown to the customer.
  • Locale: the language of the Stripe page.

Webhook ​

The CP shows the notify URL. In the Stripe Dashboard, go to Developers → Webhooks, add an endpoint with that URL and subscribe it to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed and checkout.session.expired. The webhook only tells the Store which session changed, the status is always fetched from Stripe. Without it, an order is only completed when the customer returns to your site, and a delayed payment method (e.g. Bacs Direct Debit) stays pending.

A paid order can be refunded from the CP.

Limitations ​

  • Currencies with three decimals (BHD, JOD, KWD, OMR, TND) are not supported: the checkout shows an error. Use Stripe (on-site, card) for these currencies.

Stripe (on-site, card) ​

With this gateway the customer enters their card details on your own checkout page, using the Stripe Payment Element, instead of being redirected to a page hosted by Stripe. Card details never touch your server (PCI SAQ A): Stripe.js handles the card field.

This gateway supports card payments only. For iDEAL, Bancontact and other payment methods, use the Stripe (Checkout) gateway or another gateway.

Available from v3.12.0

payment_gateway="stripe_onsite"

Usage ​

Configure Stripe (on-site, card) in the payment gateway settings in the CP:

FieldDescription
Debug modeOn uses the test fields, off uses the live fields
Test/Live Secret KeyStripe Dashboard → Developers → API keys
Test/Live Publishable KeyStripe Dashboard → Developers → API keys
Test/Live Webhook Signing SecretSee Webhook below

Template changes ​

The default templates (checkout.html, checkout-single-page.html and ajax_checkout_gateway_list_single_page.html) already support this gateway. For your own checkout template you need two things.

A mount point for the card form, on the payment step, inside your {exp:reinos_store:checkout} tag:

{exp:reinos_store:gateway_list}
    <input id="{gateway:id}" type="radio" name="gateway" value="{gateway:name}" />
    <label for="{gateway:id}">{gateway:label}</label>

    {if gateway:name == 'stripe_onsite'}
        <div id="reinos-stripe-elements-errors"></div>
        <div id="reinos-stripe-elements" style="display:none;"></div>
    {/if}
{/exp:reinos_store:gateway_list}

And, once on the same page, the tag that loads Stripe.js and the script of the module:

{exp:reinos_store:stripe_onsite_js}

The checkout form must have the id reinos-store-checkout (the default form_id of the checkout tag), and the gateway must be chosen with a radio button name="gateway", as above: with a <select>, a hidden input or the payment_gateway="stripe_onsite" param the card form is never shown.

The script does the rest: it shows the card form when the customer selects this gateway, authorizes the card when the checkout form is submitted, and then posts the form as usual. Your own submit logic does not need to change.

If your template replaces the gateway list with AJAX (like checkout-single-page.html), place the mount point outside the part that is replaced, otherwise the card form is reset on every refresh. After each refresh, call:

window.reinosStoreStripeOnsite.sync();

This keeps the card form and updates the amount when the cart total has changed.

Webhook ​

The CP shows the notify URL for this gateway. In the Stripe Dashboard, go to Developers → Webhooks and add an endpoint (a "destination") with that URL. Subscribe it to payment_intent.succeeded (completes an order that was pending), payment_intent.payment_failed and payment_intent.canceled. A refund or dispute in the Stripe dashboard does not change the order in the Store. Then copy the Signing secret (whsec_...) of that endpoint into the matching Webhook Signing Secret field in the CP.

Without a signing secret, webhook calls are still processed, but without signature verification, and an error is logged. Always set it in production.

To test webhooks locally, use the Stripe CLI and put the whsec_... it prints in the test field:

stripe listen --forward-to "<your-notify-url>"

How the payment works ​

  1. When the customer selects the gateway, a Stripe PaymentIntent is created for the current cart total and the card form is shown.
  2. On checkout, the card is authorized, not charged. 3D Secure is handled on the page by Stripe.
  3. The checkout form is then validated on the server. If validation fails, the customer returns to the form and nothing is charged. The authorization is released when the page requests a new PaymentIntent.
  4. After the order is created, the module checks the PaymentIntent (it must belong to this cart, not be used for another order, and match the order amount and currency) and only then captures the payment. The order is marked as paid.
  5. If a check fails, the order is set to failed and the inventory is restored. With a wrong amount or currency the authorization is cancelled right away, otherwise it is released when the page requests a new PaymentIntent or it expires at Stripe.
  6. When Stripe can't be reached during the capture, or Stripe is still processing it, it is unknown whether the card was charged. The order is then pending (it keeps its stock) and the customer sees the order page instead of the checkout, so they don't pay twice. The payment_intent.succeeded webhook completes the order; otherwise check the payment in the Stripe dashboard (the log names the PaymentIntent). When the capture never happened, Stripe cancels the authorization after about 7 days: the payment_intent.canceled webhook then makes the order failed (without that webhook the order stays pending until you cancel it).

Refunds can be issued from the order in the CP.

Testing ​

Use Stripe's test cards in test mode, for example:

  • 4242 4242 4242 4242: succeeds without 3D Secure
  • 4000 0025 0000 3155: requires 3D Secure
  • 4000 0000 0000 9995: is declined

Use any future expiry date and any 3-digit CVC.

If Link is enabled in your Stripe account, the card form also offers the customer to save their details with Link. The name, email and phone number from the checkout are prefilled. A customer who is logged in to Link must first click Confirm in the Link box to select a saved card, before clicking your checkout button. You can turn Link off in the Stripe Dashboard under Settings → Payment methods → Link.

More gateways ​

Available from v4.0.0

Besides the gateways above, the module ships with the following gateways. Each one sends the customer to a payment page hosted by the provider (no card data on your site). Click a gateway for its setup.

Gatewaypayment_gatewayRegionRefunds from the CP
SquaresquareUSA, Canada, UK, Ireland, France, Spain, Australia, JapanYes
Authorize.netauthorize_netUSA, Canada, UK, EU, AustraliaYes
NMInmiUSA, CanadaYes
ClovercloverUSA, CanadaYes
Elavon ConvergeconvergeUSA, CanadaYes
Mercado Pagomercado_pagoLatin AmericaYes
Pay.pay_nlNetherlands, BelgiumYes
BuckaroobuckarooNetherlands, BelgiumYes
AdyenadyenWorldwideYes
Viva.comviva_walletEuropeYes
Saferpay (Worldline)saferpaySwitzerland, EuropeYes
WorldpayworldpayUK, worldwideYes
OpayoopayoUK, IrelandNo
GoCardlessgo_cardlessUK, EurozoneYes
PayMongopaymongoPhilippinesYes
XenditxenditPhilippinesYes
MidtransmidtransIndonesiaYes
RazorpayrazorpayIndiaYes
PaystackpaystackNigeria, Ghana, South Africa, Kenya, Côte d'IvoireYes
FlutterwaveflutterwaveAfricaYes
Lemon Squeezylemon_squeezyWorldwide (merchant of record)Yes

How these gateways work ​

  1. On checkout, the module creates the payment at the provider and sends the customer to the provider's payment page.
  2. The customer returns to your site and/or the provider calls the notify URL (webhook). The CP shows this URL for gateways where you have to enter it in the provider's dashboard, together with the notify URL of versions before 4.0.0, which keeps working.
  3. In both cases the module fetches the payment from the provider's API and checks that it belongs to the order, with the right amount and currency, before it changes the order status. The content of a webhook is never trusted on its own.

Some rules that apply to all of them:

  • Debug mode switches between the test and live fields of the gateway. Every gateway has its own test or sandbox account at the provider.
  • Webhook secrets: set them. Without one, most gateways still process the webhook (the status is fetched from the API anyway) and log an error. For Clover, Authorize.net, Adyen, Buckaroo and Lemon Squeezy the secret is required and a webhook without it is refused, because there the webhook decides which payment belongs to the order.
  • A paid order is never set back to an unpaid status, for example by a late webhook of an earlier, abandoned attempt. Refunds and chargebacks are still applied.
  • A payment that doesn't match the order (another currency, or less than the order total, e.g. when the total was changed in the CP after the payment was started) doesn't mark the order as paid, with Authorize.net, Converge, Mercado Pago, Midtrans, Pay., PayMongo, Paystack, Razorpay, Square, Viva.com, Worldpay and Xendit (and Mollie, MultiSafepay and Stripe (Checkout)). The order becomes pending, also when it was cancelled, failed or expired, so the customer doesn't pay again, and the order page in the CP shows what was received. Accept it with Edit Order Details, or refund it at the provider and cancel the order. See Control Panel.
  • Currencies: some gateways only support certain currencies. The checkout shows an error when the store currency is not supported.
  • Confirmation email and hooks are sent once, by whichever comes first: the customer's return or the webhook. A customer whose payment is still pending returns to the order page.
  • A payment error releases the order: when the provider gives an error at checkout, the order becomes failed, so its stock and coupon code are free for the next attempt (see global errors).
  • Refunds are always for the full order amount. The refund button in the CP is shown for every gateway that supports refunds via its API.

Before you go live ​

These gateways were built against the providers' documentation and tested automatically, but not yet against every provider's live environment. Test each gateway you use in its test mode first:

  1. A successful payment: the order becomes complete, the customer lands on the order page and receives the confirmation email.
  2. A cancelled payment, if the provider's page has a cancel link (not Square, NMI, Converge, Razorpay and Midtrans): the customer returns to the checkout and the order becomes cancelled.
  3. A failed payment followed by a successful retry, if the provider's payment page allows retrying.
  4. The webhook: check the Store log in the CP for [NotifyEvent] lines and make sure there is no error about the webhook (e.g. webhook verification failed).
  5. A refund from the CP, if you use refunds.

Each gateway section also has a test checklist with the points specific to that provider.

Not listed ​

If you'd like to use a different Gateway, please let me know, so I can add this for you (not free).

Custom Gateways ​

Reinos Store Module Gateways are vital for your store, as they facilitate the actual payment process between your customers and you. We currently support a few native gateways. However, since there are many payment providers worldwide, we cannot support them all out of the box.

To address this, we have implemented a clean gateway system built on top of the Omnipay library, making it easy for you to add your own payment provider.

TL;DR ​

Below we will explain a bit how the file should look like, but if you don't have time to read you can just look at the file example_gateway/system/user/addons/reinos_store_gateway_test/Gateway.php that is shipped with the Reinos Store module

Folder structure ​

The folder and file structure is essential for the Reinos Store module to recognize and support your new gateway.

To add a new gateway, you need to use the following folder structure:

└── system
│   └── user
│   │   └── addons
│   │   │   └── reinos_store_gateway_<your_gateway_name>
|   │   │   │   └── Gateway.php

So if your gatway is called instapay you structure looks like

└── system
│   └── user
│   │   └── addons
│   │   │   └── reinos_store_gateway_instapay
|   │   │   │   └── Gateway.php

Gateway.php ​

The Gateway.php file is the file that connect your payment to the Reinos Store module. It should contain a couple of functions that will be called by the module.

$info varibale ​

In the $info variable we define the names and fields of this Gateway.

name ​

This is the short_name of the gateway. It should be lowercase snake_case.

public static $info = [
    'name' => 'instapay',
];
label ​

The label is displayed both in the Control Panel (CP) and in the payment selector. It represents the clean, user-friendly name of the gateway.

public static $info = [
    'label' => 'InstaPay',
];
icon / color ​

Optional. The Gateway Providers overview in the CP shows an icon in front of each gateway: the logo at the icon url (a square svg or png), or a badge with the initials of the label in the color. Without both, the badge is grey.

public static $info = [
    'icon' => 'https://example.com/instapay.svg',
    'color' => '#0d9488',
];
desc ​

The description (desc) provides details about the payment gateway, offering a clean and concise explanation. It may also include information on how to obtain the required keys.

public static $info = [
    'desc' => 'You can get your KEYS from the InstaPay dashboard (Developer --> Api Keys)',
];
showNotifyUrl ​

For some gateways, the notify URL needs to be visible to the public so it can be copied into the payment provider's dashboard. This flag enables that functionality. When set to true, the notify URL will be displayed publicly in the Control Panel (CP).

public static $info = [
    'showNotifyUrl' => true,
];
showRedirectUrl ​

Set to true when the redirect (return) URL has to be configured in the payment provider's dashboard, instead of being sent with every payment. The CP then shows the redirect URL as well.

public static $info = [
    'showRedirectUrl' => true,
];
offsite ​

Some payment providers offer an offsite payment selection, while others handle payments directly during checkout. Here, you can define whether this gateway is an offsite or onsite gateway.

public static $info = [
    'offsite' => true,
];
fields ​

Most gateways require an API key or similar credentials. With the fields section, you can define these or other necessary fields, allowing you to fill them directly within your Control Panel (CP) when enabling this gateway.

public static $info = [
     'fields' => [
        'debug' => [
            'type' => 'yes_no',
            'label' => 'Debug mode',
            'desc' => 'Enable debug mode',
        ],
        'test_api_key' => [
            'type' => 'text',
            'label' => 'Test API KEY',
        ],
        'live_api_key' => [
            'type' => 'text',
            'label' => 'Live API KEY',
        ],
    ]
];

constructor() ​

In the constructor, we create the actual payment instance using the Omnipay library. This is also where we check if an API key is set.

Depending on your gateway, there may be additional steps required, but that is up to you to implement.

Below, you'll find the bare minimum example.

public function __construct($gatewayData)
{
    // set the gateway data
    // this hold all data that was set in CP for the dynamic fields 
    $this->gatewayData = $gatewayData;

    // init the gateway from the omnipay library
    $this->gateway = \Omnipay\Omnipay::create('YourGatewayName');

    // set the api key based on the debug setting
    // this is up to you how you want to handle this
    $apiKey = $this->gatewayData['debug'] === 'y' ? $this->gatewayData['test_api_key'] : $this->gatewayData['live_api_key'];

    // pass the api key to the gateway
    $this->gateway->setApiKey($apiKey);
}

create() ​

The create() function is responsible for initiating the payment. It uses the Omnipay library's purchase() function to call the actual payment provider.

Depending on your gateway, you can extend the purchase data as needed.

 public function create($order, $currency, $gatewayData = [])
    {
        // create a purchase request
        $response = $this->gateway->purchase(
            [
                'currency' => $currency,
                'amount' => $order->total_plus_tax,
                'description' => 'Order #' . $order->order_id,
                // generate the return and notify url
                // you can use the helper functions here
                'returnUrl' => $this->generateReturnUrl($order),
                'notifyUrl' => $this->generateNotifyUrl(self::$info['name']),
                // generate the metadata, such as the order data
                'metadata' => $this->generateMetaData($order),
            ]
        )->send();

        // format the response so the Store module can handle it
        return $this->formatResponse($response, $this);
    }

saveStatus ​

The saveStatus() call is needed to define which status should be set. By default, the Omnipay library provides several methods to determine the status. You can refer to the documentation here: Omnipay Response Statuses.

Depending on your gateway implementation, you can choose to use these methods or implement your own. Below, you'll find a complete example.

protected function saveStatus($order, $transaction)
{
    // this one is a bit up to you
    // not every payment provider has the same status or has implemented the same status methods
    // below an example of how you can handle this
    if ($order && $transaction) {
        $isPaid = $transaction->isPaid();

        // the status names and the refund/chargeback methods below are the ones of the Omnipay Mollie
        // response, check which ones the response of your provider has
        $status = $transaction->getStatus();

        if ($isPaid && $transaction->hasChargebacks()) {
            $newStatus = REINOS_STORE_STATUS_PAID_CHARGEBACK;
        } elseif ($isPaid && $transaction->isRefunded() && !$transaction->isPartialRefunded()) {
            $newStatus = REINOS_STORE_STATUS_REFUNDED;
        } elseif ($isPaid) {
            // a partial refund leaves the order paid
            $newStatus = REINOS_STORE_STATUS_COMPLETE;
        } else {
            switch ($status) {
                case 'open':
                    $newStatus = REINOS_STORE_STATUS_OPEN;
                    break;
                case 'pending':
                case 'authorized':
                    $newStatus = REINOS_STORE_STATUS_PENDING;
                    break;
                case 'expired':
                    $newStatus = REINOS_STORE_STATUS_EXPIRED;
                    break;
                case 'canceled':
                    $newStatus = REINOS_STORE_STATUS_CANCELLED;
                    break;
                case 'failed':
                    $newStatus = REINOS_STORE_STATUS_FAILED;
                    break;
                default:
                    // no answer or an unknown status: leave the order as it is
                    return $order->status;
            }
        }

        // this is the most important part
        // this will update the order, inventory and sync the entry if needed
        return $this->updateVitals($order, $newStatus, $isPaid);
    }

    return false;
}

completeOrder ​

completeOrder() is called when we can complete an order. Here we fetch the transaction and call in the saveStatus()

public function completeOrder($order)
{
    // complete an order by fetching the transaction
    // and update the status based on the transaction
    $transactionResponse = $this->gateway->fetchTransaction(
        array('transactionReference' => $order->transaction_id)
    )->send();

    return $this->saveStatus($order, $transactionResponse);
}
getTransactionIdOnNotifyCall ​

When a payment provider calls the notify URL to inform you about a status update, it will usually send the transaction ID along with the request.

The getTransactionIdOnNotifyCall() function is called to fetch that ID. It's up to you and your payment provider to determine how and where to retrieve it. However, in most cases, it is sent as a POST parameter, often labeled as id.

public function getTransactionIdOnNotifyCall()
{
    return ee()->input->get_post('id');
}

HostedGateway ​

Available from v4.0.0

If your payment provider has a REST API with a hosted payment page, you don't need Omnipay. Extend HostedGateway instead of Gateway. All gateways listed above are built this way, so they are good examples (system/user/addons/reinos_store/Core/Gateway/Paymongo.php is a short one).

HostedGateway gives you:

  • jsonRequest() / httpRequest(): API calls with curl, without extra dependencies
  • modeSetting('secret_key'): reads test_secret_key or live_secret_key, depending on the debug setting
  • orderAmount() / orderAmountInMinorUnits(): the order total as "49.95" or 4995 (zero- and three-decimal currencies included)
  • redirectResponse() / formPostResponse(): send the customer to the payment page with a redirect or an auto-submitted form
  • verifyWebhook(), verifyHmac(), requestHeader(), rawRequestBody(): helpers to verify webhooks
  • saveStatus(): you only implement resolveStatus(), which maps the provider's payment to a store status. A paid order is never set back to an unpaid status.

A minimal gateway looks like this:

php
namespace Reinos\Store\Core\Gateway;

class Instapay extends HostedGateway
{
    public static $info = [
        'name' => 'instapay',
        'label' => 'InstaPay',
        'desc' => 'Get your keys from the InstaPay dashboard (Developers -> API keys)',
        'showNotifyUrl' => true,
        'offsite' => true,
        'currencies' => ['PHP'],
        'fields' => [
            'debug' => ['type' => 'yes_no', 'label' => 'Debug mode'],
            'test_secret_key' => ['type' => 'text', 'label' => 'Test Secret Key'],
            'live_secret_key' => ['type' => 'text', 'label' => 'Live Secret Key'],
            'test_webhook_secret' => ['type' => 'text', 'label' => 'Test Webhook Secret'],
            'live_webhook_secret' => ['type' => 'text', 'label' => 'Live Webhook Secret'],
        ],
    ];

    public function create($order, $currency, $gatewayData = [])
    {
        if ($error = $this->unsupportedCurrency($currency)) {
            return $this->errorResponse($error);
        }

        $result = $this->api('POST', '/payments', [
            'amount' => $this->orderAmountInMinorUnits($order, $currency),
            'currency' => $currency,
            'reference' => 'order-' . $order->order_id . '-' . $order->hash,
            'return_url' => $this->generateReturnUrl($order),
            'cancel_url' => $this->generateReturnUrl($order, '&cancel=true'),
        ]);

        if (isset($result['error'])) {
            return $result;
        }

        // the id is saved as the order's transaction_id
        return $this->redirectResponse($result['id'] ?? null, $result['payment_url'] ?? null);
    }

    public function completeOrder($order)
    {
        // always fetch the payment from the API, never trust the return URL or webhook payload
        $payment = $this->api('GET', '/payments/' . rawurlencode($order->transaction_id));

        if (isset($payment['error'])) {
            return false;
        }

        return $this->saveStatus($order, $payment);
    }

    protected function resolveStatus($payment)
    {
        switch ($payment['status'] ?? '') {
            case 'paid':
                return REINOS_STORE_STATUS_COMPLETE;
            case 'expired':
                return REINOS_STORE_STATUS_EXPIRED;
            default:
                return null; // not paid yet, leave the order as it is
        }
    }

    public function getTransactionIdOnNotifyCall()
    {
        $verified = $this->verifyWebhook($this->modeSetting('webhook_secret'), function ($secret) {
            return $this->verifyHmac($this->rawRequestBody(), $this->requestHeader('X-Signature'), $secret);
        });

        return $verified ? ($this->jsonRequestBody()['payment_id'] ?? '') : '';
    }

    private function api($method, $path, $body = null)
    {
        return $this->jsonRequest($method, 'https://api.instapay.example/v1' . $path, $body, [
            'bearer' => $this->modeSetting('secret_key'),
        ], function ($json) {
            return $json['error']['message'] ?? null;
        });
    }
}

Implement refund($order) as well when the provider supports refunds via its API; the refund button in the CP then appears automatically.

Implement transactionUrl($order) to link the transaction id on the order page in the CP to the payment in the provider's dashboard. Return the url, or null when it is not known (the default).

Implement cancelPayment($order) when the provider can cancel or expire an open payment. When the customer checks out the same cart again and the previous order is still unpaid at the provider, that order becomes failed and the Store calls cancelPayment(), so the old payment page can't be paid anymore. Return false when the provider refuses it (the Store then asks the provider about the payment once more, it may have been paid just now) and true when the payment is closed or there is nothing to close. Log errors instead of throwing them. From v4.0.0

Before you go live, check that completeOrder() verifies that the payment belongs to the order and has the right amount and currency, especially when the provider's payment page lets the customer change the amount (tips, partial payments).

For a HostedGateway, completeOrder() is also called when a customer returns to an order that was cancelled, failed or expired and isn't paid (the customer may have paid after all), just like the webhook does. Take the status from the provider's API there and don't create a new payment for such an order.