Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 049 / 100

WooCommerce payment gateways: a practical WordPress guide

Build or customize gateways.

Expona Admin8 min read

WooCommerce payment gateways: a practical WordPress guide

Payment processing is the heartbeat of any e-commerce operation, and getting it right in WooCommerce determines whether customers complete purchases or abandon carts. Building or customizing a payment gateway might sound complex, but with the right foundation and tested patterns, you can integrate new processors, add custom logic, and handle asynchronous payment flows reliably. This guide walks through the essentials we've learned integrating everything from Stripe and PayPal alternatives to regional processors in high-volume stores.

Extend WC_Payment_Gateway

Every payment gateway in WooCommerce inherits from WC_Payment_Gateway, which handles settings registration, payment form rendering, and order status updates. This class is your contract with WooCommerce; implement its required methods and you unlock the full ecosystem.

Start by registering your gateway class. WooCommerce discovers gateways through a filter hook:

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

Your gateway class needs at least these methods:

php
class WC_Gateway_Custom_Processor extends WC_Payment_Gateway {
    public function __construct() {
        $this->id                 = 'custom_processor';
        $this->method_title       = 'Custom Processor';
        $this->method_description = 'Accept payments via Custom Processor';
        $this->has_fields         = false;
        $this->supports           = array( 'products', 'refunds' );
        
        $this->init_form_fields();
        $this->init_settings();
        
        $this->title       = $this->get_option( 'title' );
        $this->description = $this->get_option( 'description' );
        $this->api_key     = $this->get_option( 'api_key' );
        
        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(
                'title'   => 'Enable/Disable',
                'type'    => 'checkbox',
                'label'   => 'Enable Custom Processor',
                'default' => 'yes',
            ),
            'title' => array(
                'title'       => 'Title',
                'type'        => 'text',
                'description' => 'What customers see at checkout',
                'default'     => 'Custom Processor',
            ),
            'api_key' => array(
                'title'       => 'API Key',
                'type'        => 'password',
                'description' => 'Your API key from Custom Processor',
            ),
        );
    }
    
    public function process_payment( $order_id ) {
        $order = wc_get_order( $order_id );
        
        try {
            $response = $this->charge_card( $order );
            
            if ( $response['success'] ) {
                $order->payment_complete( $response['transaction_id'] );
                WC()->cart->empty_cart();
                return array(
                    'result'   => 'success',
                    'redirect' => $this->get_return_url( $order ),
                );
            } else {
                wc_add_notice( 'Payment failed: ' . $response['message'], 'error' );
                return array( 'result' => 'fail' );
            }
        } catch ( Exception $e ) {
            wc_add_notice( 'Payment error: ' . $e->getMessage(), 'error' );
            return array( 'result' => 'fail' );
        }
    }
    
    private function charge_card( $order ) {
        // API call to your processor
        $response = wp_remote_post( 'https://api.customprocessor.com/charge', array(
            'body' => wp_json_encode( array(
                'amount'  => $order->get_total(),
                'currency' => get_woocommerce_currency(),
                'description' => 'Order #' . $order->get_id(),
            ) ),
            'headers' => array(
                'Authorization' => 'Bearer ' . $this->api_key,
                'Content-Type'  => 'application/json',
            ),
            'timeout' => 30,
        ) );
        
        if ( is_wp_error( $response ) ) {
            throw new Exception( $response->get_error_message() );
        }
        
        return json_decode( wp_remote_retrieve_body( $response ), true );
    }
}

The process_payment method is your workhorse—it charges the card synchronously, updates the order, and returns success or failure. For async processors, you'll handle this differently, which we cover next.

Support blocks checkout

The modern WooCommerce checkout is built on blocks, and payments must integrate cleanly. Rather than relying on the legacy shortcode checkout, you need to register your gateway for the Checkout block.

WooCommerce provides a REST endpoint that blocks checkout communicates with. To make your gateway available there, register it via the payment methods registry in JavaScript. First, ensure your gateway declares support in the constructor:

php
$this->supports = array( 'products', 'refunds' );

Then create a JavaScript file that registers the payment method:

php
// In your gateway class init:
wp_register_script(
    'wc-custom-processor',
    plugin_dir_url( __FILE__ ) . 'payment-method.js',
    array( 'wc-blocks-checkout' ),
    '1.0.0',
    true
);

add_action( 'woocommerce_blocks_payment_method_type_registration', function( $payment_methods ) {
    $payment_methods->register( new WC_Custom_Processor_Blocks() );
} );

Create a Blocks integration class:

php
class WC_Custom_Processor_Blocks implements Automattic\WooCommerce\Blocks\Payments\PaymentMethodTypeInterface {
    private $gateway;
    
    public function __construct() {
        $this->gateway = new WC_Gateway_Custom_Processor();
    }
    
    public function get_name() {
        return 'custom_processor';
    }
    
    public function get_payment_method_script_handles() {
        return array( 'wc-custom-processor' );
    }
    
    public function get_payment_method_data() {
        return array(
            'title'       => $this->gateway->get_option( 'title' ),
            'description' => $this->gateway->get_option( 'description' ),
            'supports'    => $this->gateway->supports,
        );
    }
}

On the frontend, your JavaScript payment method handles submission and error states. Blocks checkout sends the payment details via REST, so ensure your endpoint validates and processes correctly.

Handle webhooks for async payments

Not all payments complete synchronously. Services like Stripe or regional processors often require you to poll for status or listen to webhooks. Webhook handling is critical—a missing or failed webhook means a customer paid but their order status never updates.

Register a REST endpoint that your processor calls:

php
add_action( 'rest_api_init', function() {
    register_rest_route( 'custom-processor/v1', '/webhook', array(
        'methods'             => 'POST',
        'callback'            => array( $this, 'handle_webhook' ),
        'permission_callback' => '__return_true', // Verify signature instead
    ) );
} );

In the webhook handler, always verify the signature and idempotency:

php
public function handle_webhook( WP_REST_Request $request ) {
    $body      = $request->get_body();
    $signature = $request->get_header( 'X-Signature' );
    
    // Verify webhook authenticity
    $expected_signature = hash_hmac( 'sha256', $body, $this->api_secret );
    if ( ! hash_equals( $signature, $expected_signature ) ) {
        return new WP_REST_Response( 'Invalid signature', 401 );
    }
    
    $event = json_decode( $body, true );
    
    // Check if we've already processed this event
    $processed = get_transient( 'cp_webhook_' . $event['id'] );
    if ( $processed ) {
        return new WP_REST_Response( 'Already processed', 200 );
    }
    
    if ( 'payment.completed' === $event['type'] ) {
        $order = wc_get_order_by_order_key( $event['order_key'] );
        if ( $order ) {
            $order->payment_complete( $event['transaction_id'] );
            $order->add_order_note( 'Payment confirmed via webhook.' );
        }
        
        set_transient( 'cp_webhook_' . $event['id'], true, HOUR_IN_SECONDS );
    }
    
    return new WP_REST_Response( 'OK', 200 );
}

The transient check prevents duplicate processing if the webhook is sent twice. Always store the webhook event ID or a hash to verify idempotency. In production, consider logging all webhooks to a custom table for audit trails.

Test with sandbox accounts

Before going live, you need a safe sandbox environment. Most payment processors provide test credentials that return predictable responses without charging real money.

Add a test mode toggle to your gateway settings:

php
'test_mode' => array(
    'title'   => 'Test Mode',
    'type'    => 'checkbox',
    'label'   => 'Enable test mode (uses sandbox API)',
    'default' => 'yes',
),

Then switch endpoints based on this setting:

php
private function get_api_endpoint() {
    $test_mode = 'yes' === $this->get_option( 'test_mode' );
    if ( $test_mode ) {
        return 'https://sandbox-api.customprocessor.com';
    }
    return 'https://api.customprocessor.com';
}

Use test card numbers provided by your processor. Common patterns include cards ending in certain numbers that trigger specific responses: declined, fraud, etc. Document these in a development wiki so your team knows which card to use for which scenario.

Test order workflows end-to-end: successful payment, failed payment, refund, webhook retry, and timeout scenarios. Use WP-CLI to create test orders in bulk if needed.

Common mistakes

  • Not validating webhook signatures. Any attacker can forge webhooks. Always verify the signature using your API secret.
  • Missing idempotency on webhooks. Webhooks can retry; without deduplication, an order might be marked paid twice or refunded multiple times.
  • Storing raw credit card data. This violates PCI compliance. Use tokenization APIs and store only tokens.
  • Assuming synchronous payment always succeeds. Network timeouts happen. Log responses and implement retry logic with exponential backoff.
  • Forgetting to test the blocks checkout path. If you only test the legacy shortcode, the new checkout silently fails for users.
  • Hardcoding API credentials. Always use WordPress options or environment variables; never commit keys to the repository.

Key takeaways

  • Extend WC_Payment_Gateway, implement process_payment, and register via the woocommerce_payment_gateways filter to create a functioning gateway.
  • Blocks checkout requires a separate JavaScript registration and a Blocks integration class; don't assume legacy shortcode support is enough.
  • Handle async payments via webhooks, always verify signatures, and use transients or a log table to ensure idempotency.
  • Test thoroughly in sandbox mode, documenting test card numbers and error scenarios your team will encounter.
  • Prioritize security: validate input, store tokens not cards, and protect your API keys.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles