Build a custom WooCommerce payment gateway
WooCommerce does not need a separate plugin for every checkout method. A merchant can create a custom payment gateway by extending WC_Payment_Gateway, registering the class with WooCommerce, and mapping a processor response to a WooCommerce order. The simplest dependable design is a hosted redirect: WordPress creates the order and requests a secure payment page where the customer completes the transaction.
Card details never need to pass through WordPress in this model, which helps keep the site outside the processor’s card-data environment. It does not make the extension risk-free. The gateway still handles billing data, order totals, return addresses, transaction references and payment notifications, so input validation, authenticated API calls and careful status changes are essential.
An Australian store will normally price in AUD and display GST-inclusive amounts, while many customers pay with Visa, Mastercard or American Express. Bank transfer and PayID are familiar everyday options, and services such as PayPal and buy-now-pay-later products already occupy a significant part of the local market. A basic custom gateway should present only the methods the connected processor genuinely supports.
The Privacy Act 1988 and Australian Privacy Principles can apply to customer billing and delivery records, including small businesses covered by the Act. Shoppers in Sydney, Melbourne, Brisbane and regional centres often buy from a phone, so the redirect, hosted payment form and order-received page must remain fast and readable. Clear failure handling is also important when a shopper loses mobile coverage.
Create the plugin and gateway class
Build the extension as a small plugin instead of editing the active theme. Themes are routinely replaced during updates; a dedicated plugin can be version-controlled, tested on staging and activated without changing the design layer. Give it a bootstrap file and an includes directory, and load the gateway only after WooCommerce has supplied WC_Payment_Gateway.
The plugin header makes the extension recognisable in the WordPress admin area. Its registration filter adds the class name to the list WooCommerce uses at checkout and in payment settings.
<?php
/*
Plugin Name: Basic WooCommerce Gateway
Description: Hosted-payment integration for WooCommerce.
Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
add_action(
'plugins_loaded',
function () {
if ( ! class_exists( 'WC_Payment_Gateway' ) ) {
return;
}
require_once __DIR__ . '/includes/class-wc-gateway-basic.php';
add_filter(
'woocommerce_payment_gateways',
function ( $gateways ) {
$gateways[] = 'WC_Gateway_Basic';
return $gateways;
}
);
},
20
);
Put the implementation in includes/class-wc-gateway-basic.php. Define the identifier, customer-facing label, supported order types and settings before WooCommerce renders the available payment methods. Keeping processor-specific work inside this class lets other extensions continue using standard WooCommerce hooks.
Configure gateway settings and checkout presentation
Give the gateway separate sandbox and live connection values. A test switch then controls which endpoint and credential are used, avoiding the common mistake of leaving a sandbox URL in production. API keys and webhook secrets should be password fields, and the existing WooCommerce capability checks and nonce protection should control access to the administrator screen.
The following methods belong inside WC_Gateway_Basic. The testmode setting defaults to enabled, so the first request cannot reach live funds.
public function __construct() {
$this->id = 'basic';
$this->method_title = 'Basic processor';
$this->method_description = 'Pay through a secure hosted payment page.';
$this->has_fields = false;
$this->supports = array( 'products' );
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option( 'title', 'Secure payment' );
$this->description = $this->get_option( 'description', '' );
$this->enabled = $this->get_option( 'enabled', 'no' );
add_action(
'woocommerce_update_options_payment_gateways_' . $this->id,
array( $this, 'process_admin_options' )
);
}
public function init_form_fields() {
$this->form_fields = array(
'enabled' => array(
'type' => 'checkbox',
'label' => 'Enable Basic processor',
'default' => 'no',
),
'title' => array(
'type' => 'text',
'label' => 'Customer-facing title',
'default' => 'Secure payment',
),
'description' => array(
'type' => 'textarea',
'label' => 'Checkout description',
'default' => 'Complete payment on the secure provider page.',
),
'testmode' => array(
'type' => 'checkbox',
'label' => 'Use sandbox mode',
'default' => 'yes',
),
'sandbox_url' => array(
'type' => 'url',
'label' => 'Sandbox API URL',
),
'live_url' => array(
'type' => 'url',
'label' => 'Live API URL',
),
'sandbox_key' => array(
'type' => 'password',
'label' => 'Sandbox API key',
),
'live_key' => array(
'type' => 'password',
'label' => 'Live API key',
),
'webhook_secret' => array(
'type' => 'password',
'label' => 'Webhook signing secret',
),
);
}
After activation, open WooCommerce’s payment settings, enter the HTTPS endpoints and credentials, enable the gateway and save. get_option() then supplies the stored values at runtime. Secrets belong only in server-side settings, never in checkout JavaScript, customer-visible HTML or source-controlled files.
Process orders through a hosted payment page
At checkout, WooCommerce creates the order and calls process_payment() after the customer selects the gateway. The amount and currency must come from that saved order, never from hidden checkout fields. Send integer cents, an order identifier, the success and cancellation URLs and a callback URL to the processor. Because this gateway redirects, it has no card-number fields to render.
A hosted-page API request should be server-to-server, authenticated and sent with a timeout. An idempotency key based on the order ID lets the processor recognise a repeated request if the shopper or network retries it.
protected function connection() {
$sandbox = 'yes' === $this->get_option( 'testmode', 'yes' );
return array(
'url' => $this->get_option( $sandbox ? 'sandbox_url' : 'live_url' ),
'key' => $this->get_option( $sandbox ? 'sandbox_key' : 'live_key' ),
);
}
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
wc_add_notice( 'The order could not be loaded.', 'error' );
return array( 'result' => 'failure' );
}
$connection = $this->connection();
if ( empty( $connection['url'] ) || empty( $connection['key'] ) ) {
wc_add_notice( 'This payment method is not configured.', 'error' );
return array( 'result' => 'failure' );
}
$amount = (int) round(
(float) wc_format_decimal( $order->get_total(), 2 ) * 100
);
$payload = array(
'order_id' => $order->get_id(),
'reference' => $order->get_order_number(),
'amount_cents' => $amount,
'currency' => $order->get_currency(),
'success_url' => $this->get_return_url( $order ),
'cancel_url' => $order->get_checkout_payment_url(),
'webhook_url' => rest_url( 'basic-gateway/v1/webhook' ),
);
$response = wp_safe_remote_post(
$connection['url'],
array(
'timeout' => 20,
'headers' => array(
'Authorization' => 'Bearer ' . $connection['key'],
'Content-Type' => 'application/json',
'Idempotency-Key' => 'woo-' . $order->get_id(),
),
'body' => wp_json_encode( $payload ),
'data_format' => 'body',
)
);
if ( is_wp_error( $response ) ) {
wc_add_notice( 'The payment provider could not be reached.', 'error' );
return array( 'result' => 'failure' );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code < 200 || $code >= 300 || ! is_array( $data ) ) {
wc_add_notice( 'The payment provider rejected the request.', 'error' );
return array( 'result' => 'failure' );
}
$checkout_url = esc_url_raw( $data['checkout_url'] ?? '' );
$expected_host = wp_parse_url( $connection['url'], PHP_URL_HOST );
$actual_host = wp_parse_url( $checkout_url, PHP_URL_HOST );
$scheme = wp_parse_url( $checkout_url, PHP_URL_SCHEME );
if (
! $checkout_url ||
'https' !== $scheme ||
$expected_host !== $actual_host
) {
wc_add_notice( 'The provider returned an invalid payment URL.', 'error' );
return array( 'result' => 'failure' );
}
$transaction_id = sanitize_text_field( $data['transaction_id'] ?? '' );
if ( ! $transaction_id ) {
wc_add_notice( 'The provider did not return a transaction reference.', 'error' );
return array( 'result' => 'failure' );
}
$order->update_meta_data( '_basic_transaction_id', $transaction_id );
$order->update_meta_data( '_basic_amount_cents', $amount );
$order->update_meta_data( '_basic_currency', $order->get_currency() );
$order->update_status( 'pending', 'Awaiting processor confirmation.', true );
$order->save();
WC()->cart->empty_cart();
return array(
'result' => 'success',
'redirect' => $checkout_url,
);
}
Treat the redirect as the start of payment, not proof of it. The order remains pending until the processor confirms the result. Store the transaction reference, amount and currency for comparison with the callback, and require both HTTPS and the configured processor host before sending the shopper away. If the API fails, show a generic checkout error and leave the order available for retry.
Confirm payment with a signed webhook
A browser return URL is useful for the shopper but unreliable as proof because it can be closed or edited. The processor should call a signed webhook when payment reaches a final state. The endpoint below verifies an HMAC SHA-256 signature against the exact request body, then checks the order, amount, currency and transaction reference before changing a status.
Add add_action( 'rest_api_init', array( $this, 'register_webhook' ) ); to the constructor. The route must be publicly reachable, so its secret signature check is the actual authorisation boundary. Returning success for a repeated paid notification makes webhook handling idempotent.
public function register_webhook() {
register_rest_route(
'basic-gateway/v1',
'/webhook',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'handle_webhook' ),
'permission_callback' => '__return_true',
)
);
}
public function handle_webhook( WP_REST_Request $request ) {
$body = $request->get_body();
$signature = (string) $request->get_header( 'x-basic-signature' );
$secret = (string) $this->get_option( 'webhook_secret' );
$expected = hash_hmac( 'sha256', $body, $secret );
if ( ! $secret || ! $signature || ! hash_equals( $expected, $signature ) ) {
return new WP_Error( 'invalid_signature', 'Invalid signature.', array( 'status' => 401 ) );
}
$data = json_decode( $body, true );
$order = wc_get_order( absint( $data['order_id'] ?? 0 ) );
if ( ! is_array( $data ) || ! $order ) {
return new WP_Error( 'invalid_order', 'Order not found.', array( 'status' => 404 ) );
}
$amount = (int) ( $data['amount_cents'] ?? -1 );
$currency = strtoupper( (string) ( $data['currency'] ?? '' ) );
if (
$amount !== (int) $order->get_meta( '_basic_amount_cents' ) ||
$currency !== strtoupper( (string) $order->get_meta( '_basic_currency' ) )
) {
return new WP_Error( 'amount_mismatch', 'Payment details do not match.', array( 'status' => 409 ) );
}
$transaction = sanitize_text_field( $data['transaction_id'] ?? '' );
$stored = (string) $order->get_meta( '_basic_transaction_id' );
if ( ! $transaction || ! $stored || ! hash_equals( $stored, $transaction ) ) {
return new WP_Error( 'transaction_mismatch', 'Transaction does not match.', array( 'status' => 409 ) );
}
$state = sanitize_key( $data['status'] ?? '' );
if ( 'paid' === $state && ! $order->is_paid() ) {
$order->payment_complete( $transaction );
$order->add_order_note( 'Payment confirmed. Transaction: ' . $transaction );
} elseif ( 'failed' === $state && ! $order->is_paid() ) {
$order->update_status( 'failed', 'The processor reported a failed payment.', true );
}
return rest_ensure_response( array( 'received' => true ) );
}
Only accept defined states such as paid or failed; ignore unknown states rather than marking an order successful. Keep the gateway read-only regarding captured amounts: the webhook confirms the amount created for the order, while refunds or captures should follow the processor’s documented workflow. Add an order note containing the transaction reference, but never store card details or secrets.
Test and operate the gateway safely
Exercise the complete flow in sandbox mode before switching to live credentials. Test a successful payment, a decline, a cancelled browser return, a delayed notification and a duplicate webhook. Use products with and without stock control, then confirm the expected WooCommerce order statuses, emails, GST-inclusive totals and stock behaviour. Replace sandbox keys with live values only after a low-value transaction has been reconciled in the processor dashboard.
Treat the WordPress host, WooCommerce version, PHP version and plugin dependencies as part of the payment system. Require TLS for WordPress, the processor API and webhook callback, restrict admin access, rotate credentials and keep full payment data out of PHP logs and order notes. Validate each webhook before reading its JSON, compare its amount and currency with the order, and reject checkout redirects that use HTTP or an unexpected host.
Privacy obligations continue after checkout. Under the Privacy Act 1988 and the Australian Privacy Principles, keep the minimum billing and delivery data needed for fulfilment, restrict staff access and define how long it is retained. If customer data is sent offshore, document the disclosure and obtain appropriate contractual assurances. Australian Consumer Law requirements also mean refund and cancellation messaging shown during checkout should match the store’s actual policy.
Operational ownership matters after launch. Give the store owner a documented path for failed payments, abandoned sessions, duplicate notifications, processor outages, refunds and payment disputes. Configure monitoring for repeated API failures and unexpected webhook responses, and remove expired sandbox credentials. This approach keeps card entry on the provider’s domain, preserves the WooCommerce order as the fulfilment record and provides a maintainable foundation for additional payment methods without exposing sensitive card data.