Code editor showing WooCommerce payment gateway plugin development

Building a custom WooCommerce payment gateway plugin is one of those tasks that seems manageable until you are deep into the details. The WooCommerce documentation gives you the basics of extending the gateway class, but the real education comes from implementation: handling provider API quirks, managing order status transitions, dealing with asynchronous payment confirmations, and understanding what happens when the checkout flow breaks in ways the documentation does not cover. This tutorial walks through the full process of building a payment gateway plugin, from initial file structure through admin configuration, checkout integration, request handling, and response processing. It also covers the common mistakes and the merchant reality that developers in certain markets face when the standard gateway options are not available. For the official payment gateway API reference, see the WooCommerce developer documentation. The WooCommerce hub links to the rest of the payment integration series.

When a Custom Payment Gateway Makes Sense

The default WooCommerce installation ships with PayPal and basic bank transfer support. The plugin ecosystem adds Stripe, Square, Authorize.net, and dozens of other providers through official and third party extensions. For stores operating in North America and Western Europe, these options cover most needs.

But there are entire markets where none of those gateways work. Mobile money providers in East Africa. Bank specific payment APIs in Southeast Asia. Regional payment networks that have no WooCommerce plugin and no plans to build one. In those markets, building a custom gateway is not a luxury. It is the only way to accept payments from the customer base the store actually serves.

A custom gateway also makes sense when a client needs tight integration with an internal payment system, when the available third party plugin does not support the specific API version the provider requires, or when the existing plugin introduces performance or compatibility problems that cannot be resolved through configuration. Building your own gives you full control over the implementation and full visibility into every request and response.

Plugin Structure

A WooCommerce payment gateway plugin follows the standard WordPress plugin structure with a few WooCommerce specific requirements. The minimum viable structure includes the main plugin file with the plugin header, a gateway class that extends WC_Payment_Gateway, and the registration hook that adds your gateway to the WooCommerce payment methods list.

/**
 * Plugin Name: Custom Payment Gateway
 * Description: Custom payment gateway for WooCommerce.
 * Version: 1.0.0
 * Requires at least: 5.8
 * Requires PHP: 7.4
 * WC requires at least: 6.0
 */

defined( 'ABSPATH' ) || exit;

add_action( 'plugins_loaded', 'init_custom_gateway' );

function init_custom_gateway() {
    if ( ! class_exists( 'WC_Payment_Gateway' ) ) {
        return;
    }

    class WC_Custom_Gateway extends WC_Payment_Gateway {

        public function __construct() {
            $this->id                 = 'custom_gateway';
            $this->method_title       = 'Custom Gateway';
            $this->method_description = 'Accept payments through Custom Provider.';
            $this->has_fields         = false;
            $this->supports           = array( 'products' );

            $this->init_form_fields();
            $this->init_settings();

            $this->title       = $this->get_option( 'title' );
            $this->description = $this->get_option( 'description' );
            $this->enabled     = $this->get_option( 'enabled' );
            $this->testmode    = 'yes' === $this->get_option( 'testmode' );
            $this->api_key     = $this->get_option( 'api_key' );
            $this->api_secret  = $this->get_option( 'api_secret' );

            add_action(
                'woocommerce_update_options_payment_gateways_' . $this->id,
                array( $this, 'process_admin_options' )
            );
        }
    }

    add_filter( 'woocommerce_payment_gateways', function( $gateways ) {
        $gateways[] = 'WC_Custom_Gateway';
        return $gateways;
    });
}

The class constructor sets up the gateway identifier, display properties, and loads settings from the database. The supports array declares what WooCommerce features this gateway handles. At minimum, most gateways support 'products'. If you plan to support refunds through the admin, add 'refunds' to the array and implement the process_refund method.

Admin Settings

The init_form_fields method defines the settings that appear on the WooCommerce payment gateway configuration page. This is where the store operator configures API keys, toggles test mode, and sets the customer facing payment method title and description.

public function init_form_fields() {
    $this->form_fields = array(
        'enabled' => array(
            'title'   => 'Enable/Disable',
            'type'    => 'checkbox',
            'label'   => 'Enable Custom Gateway',
            'default' => 'no',
        ),
        'title' => array(
            'title'       => 'Title',
            'type'        => 'text',
            'description' => 'Payment method title shown at checkout.',
            'default'     => 'Custom Payment',
        ),
        'description' => array(
            'title'       => 'Description',
            'type'        => 'textarea',
            'description' => 'Payment method description shown at checkout.',
            'default'     => 'Pay using Custom Provider.',
        ),
        'testmode' => array(
            'title'   => 'Test Mode',
            'type'    => 'checkbox',
            'label'   => 'Enable test mode',
            'default' => 'yes',
        ),
        'api_key' => array(
            'title' => 'API Key',
            'type'  => 'text',
        ),
        'api_secret' => array(
            'title' => 'API Secret',
            'type'  => 'password',
        ),
    );
}

A common oversight is not separating test mode credentials from production credentials. In practice, most providers issue different API keys for sandbox and live environments. Consider adding separate field pairs for test and production credentials and using the testmode toggle to determine which pair gets used. This prevents the dangerous pattern of store operators testing with production keys or going live with sandbox keys.

Checkout Flow

The process_payment method is the core of the checkout integration. WooCommerce calls this method when the customer submits the checkout form with your gateway selected. It receives the order ID and is responsible for initiating the payment, handling the provider response, and returning the appropriate result to WooCommerce.

public function process_payment( $order_id ) {
    $order = wc_get_order( $order_id );

    $api_url = $this->testmode
        ? 'https://sandbox.provider.com/api/pay'
        : 'https://api.provider.com/api/pay';

    $payload = array(
        'amount'      => $order->get_total(),
        'currency'    => $order->get_currency(),
        'reference'   => $order->get_order_number(),
        'callback_url'=> home_url( '/wc-api/custom_gateway_callback' ),
        'return_url'  => $this->get_return_url( $order ),
    );

    $response = wp_remote_post( $api_url, array(
        'timeout' => 30,
        'headers' => array(
            'Authorization' => 'Bearer ' . $this->api_key,
            'Content-Type'  => 'application/json',
        ),
        'body' => wp_json_encode( $payload ),
    ));

    if ( is_wp_error( $response ) ) {
        wc_add_notice( 'Payment error: unable to reach payment provider.', 'error' );
        return array( 'result' => 'failure' );
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    $code = wp_remote_retrieve_response_code( $response );

    if ( $code !== 200 || empty( $body['payment_url'] ) ) {
        $order->add_order_note( 'Payment initiation failed. Provider response: '
            . wp_remote_retrieve_body( $response ) );
        wc_add_notice( 'Payment could not be initiated. Please try again.', 'error' );
        return array( 'result' => 'failure' );
    }

    $order->update_meta_data( '_custom_gateway_txn_id', $body['transaction_id'] );
    $order->save();

    return array(
        'result'   => 'success',
        'redirect' => $body['payment_url'],
    );
}

Several things in this method deserve attention. The timeout is set to 30 seconds because payment provider APIs in some markets have higher latency than developers in faster infrastructure regions expect. The callback URL uses the WooCommerce API endpoint convention, which lets you register a handler that WooCommerce routes to automatically. The transaction ID from the provider response is saved as order meta for later reference during callback processing. And the error handling provides useful information in the order notes for the store operator while showing a generic message to the customer. Never expose raw provider error details to the customer.

Request Handling

The outgoing request to the payment provider needs to include exactly the fields the provider expects, in the format they expect, with the authentication they require. This sounds obvious, but provider documentation quality varies enormously. Some providers have clear, versioned API documentation with working code samples. Others have PDF documents that describe a different API version than what is actually running in production.

When the documentation is unclear, the most reliable approach is to start with the provider's sandbox and capture the raw request and response for every test. Use the WordPress HTTP API logging or a proxy tool to see exactly what goes over the wire. Compare that against the documentation and note any discrepancies. Those discrepancies are the things that will break the integration later when the provider updates their API or when you move from sandbox to production.

Request signing, where the provider requires an HMAC or hash over the request body or specific fields, is a common source of implementation bugs. The signing algorithm, the field ordering, the encoding of special characters, and the inclusion or exclusion of null fields all need to match exactly what the provider's validation expects. Get any of those wrong and you get a generic "invalid signature" error that tells you nothing about which part of the signing is incorrect.

Response Handling

Payment responses come in two forms: synchronous responses to the initial payment request, and asynchronous callbacks sent by the provider after payment processing completes. Most payment providers use both. The synchronous response tells you whether the payment was initiated successfully and provides a redirect URL or transaction ID. The asynchronous callback tells you whether the payment was completed, failed, or is still pending.

The callback handler is where most gateway implementations fail quietly. It needs to validate the request source, parse the payment status, match the transaction to the correct order, update the order status, and return the appropriate HTTP response code. Missing any of those steps means either orders stuck in pending, duplicate order updates, or the provider retrying callbacks because it did not receive the expected response.

add_action( 'woocommerce_api_custom_gateway_callback', 'handle_gateway_callback' );

function handle_gateway_callback() {
    $raw = file_get_contents( 'php://input' );
    $data = json_decode( $raw, true );

    if ( empty( $data['transaction_id'] ) || empty( $data['status'] ) ) {
        status_header( 400 );
        exit( 'Invalid payload' );
    }

    // Verify signature if provider signs callbacks
    // ...

    $orders = wc_get_orders( array(
        'meta_key'   => '_custom_gateway_txn_id',
        'meta_value' => sanitize_text_field( $data['transaction_id'] ),
        'limit'      => 1,
    ));

    if ( empty( $orders ) ) {
        status_header( 404 );
        exit( 'Order not found' );
    }

    $order = $orders[0];

    // Idempotency: skip if already processed
    if ( $order->is_paid() ) {
        status_header( 200 );
        exit( 'Already processed' );
    }

    if ( $data['status'] === 'completed' ) {
        $order->payment_complete( $data['transaction_id'] );
        $order->add_order_note( 'Payment confirmed via callback.' );
    } elseif ( $data['status'] === 'failed' ) {
        $order->update_status( 'failed', 'Payment failed per provider callback.' );
    }

    status_header( 200 );
    exit( 'OK' );
}

Notice the idempotency check. Payment providers sometimes send the same callback multiple times, either because of network issues or because their retry mechanism fires before your response was received. Without the idempotency check, you could end up processing the same successful payment twice, which can cause accounting errors or trigger duplicate order confirmation emails.

Testing Concerns

Testing a payment gateway requires more than verifying the happy path. You need to test successful payments, declined payments, timed out requests, malformed callbacks, duplicate callbacks, and the scenario where the customer abandons the redirect flow without completing payment. Each of those scenarios exercises different code paths and each represents a real failure mode.

The sandbox testing guide covers the practical details of setting up test environments and validating behavior. The sandbox to production integration guide covers the transition from test to live transactions.

Common Mistakes

After building and reviewing multiple custom gateway implementations, the same mistakes come up repeatedly. Insufficient error handling that turns provider errors into vague WooCommerce messages. Callback endpoints that do not validate the request source. Logging that captures credit card numbers or API secrets. Checkout JavaScript that breaks on mobile because it was only tested on desktop. Order status transitions that do not account for duplicate or out of order callbacks. Test mode toggles that share credentials with production. Timeout values that are too short for providers with higher API latency.

Each of these is preventable with straightforward coding discipline. The challenge is that the happy path testing passes and these edge cases only surface under real transaction conditions. Building defensively from the start is cheaper than debugging in production.

Merchant Reality in Non Standard Markets

If you are building payment gateways for providers in East Africa, South Asia, or other developing markets, there are additional realities to account for. Provider documentation may be incomplete or outdated. Sandbox environments may not fully replicate production behavior. API response times may be higher due to infrastructure constraints. Customer payment methods may include mobile money, bank transfers, and carrier billing rather than credit cards. Currency handling may involve non standard formatting. And the provider's technical support may operate on timezones and response schedules that do not align with your development workflow.

None of these are reasons to avoid the work. They are reasons to build more carefully, log more thoroughly, and test more patiently. The stores that need these custom gateways are typically the ones with the fewest alternative options, which means the quality of your implementation directly affects whether the store can function at all.

For the next step in the payment integration series, continue to integrating from sandbox to production. For the webhook endpoint patterns that complement this guide, see receiving data via REST API from callbacks and webhooks.