Guides / WooCommerce

How to Build a Custom WooCommerce Payment Gateway Plugin

Build a Custom Payment Gateway Plugin: Settings, payments, refunds, webhooks and block checkout.

If your payment provider has no WooCommerce plugin, or the one it has doesn’t fit, you can write your own. A WooCommerce payment gateway is an ordinary WordPress plugin with one class that extends WC_Payment_Gateway. This tutorial builds a working gateway step by step: settings, taking a payment, refunds, webhooks, block checkout support and HPOS compatibility. Each piece is shown in code you can copy.

How do I build a custom WooCommerce payment gateway?

Short answer: Create a plugin and, once WooCommerce has loaded, define a class that extends WC_Payment_Gateway. Add it to the woocommerce_payment_gateways filter. Define its settings in init_form_fields(), charge the customer in process_payment() by calling your provider’s API, and add process_refund() for refunds. Then register a block checkout integration and declare HPOS compatibility.

New to how gateways, tokenization and PCI fit together? Read the WooCommerce payment gateway development guide first, then come back here to build one.

Before you start

  • A local WordPress site with WooCommerce, such as one made with LocalWP or Laragon
  • Sandbox API credentials from your payment provider
  • Your provider’s API documentation, especially how to charge a token, refund and verify webhooks
  • Working knowledge of PHP and a little JavaScript for the block checkout

The examples use a made-up provider called “Acme Pay” with a REST API at api.acmepay.example. Replace the endpoints and field names with your provider’s.

Step 1: Create the plugin file

Create the folder wp-content/plugins/acme-pay-gateway/ with a main file acme-pay-gateway.php. The gateway class is loaded on plugins_loaded, so WooCommerce’s base class exists by then.

<?php
/**
 * Plugin Name: Acme Pay Gateway for WooCommerce
 * Description: Take payments through Acme Pay.
 * Version: 1.0.0
 * Requires Plugins: woocommerce
 * Text Domain: acme-pay-gateway
 */

defined( 'ABSPATH' ) || exit;

define( 'ACME_PAY_FILE', __FILE__ );

add_action( 'plugins_loaded', function () {
	if ( ! class_exists( 'WC_Payment_Gateway' ) ) {
		return; // WooCommerce is not active.
	}
	require_once __DIR__ . '/includes/class-acme-pay-gateway.php';

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

Step 2: Write the gateway class and its settings

The constructor sets the gateway’s ID, title and supported features, loads the settings and hooks up saving. init_form_fields() defines what appears under WooCommerce › Settings › Payments.

<?php
// includes/class-acme-pay-gateway.php
defined( 'ABSPATH' ) || exit;

class Acme_Pay_Gateway extends WC_Payment_Gateway {

	// Declared up front: PHP 8.2+ deprecates creating properties on the fly.
	protected $testmode = false;
	protected $api_key  = '';

	public function __construct() {
		$this->id                 = 'acme_pay';
		$this->method_title       = 'Acme Pay';
		$this->method_description = 'Take card payments through Acme Pay.';
		$this->has_fields         = true;
		$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->testmode    = 'yes' === $this->get_option( 'testmode' );
		$this->api_key     = $this->testmode ? $this->get_option( 'test_api_key' ) : $this->get_option( 'api_key' );

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

	public function init_form_fields() {
		$this->form_fields = array(
			'enabled'        => array(
				'title'   => 'Enable/Disable',
				'type'    => 'checkbox',
				'label'   => 'Enable Acme Pay',
				'default' => 'no',
			),
			'title'          => array(
				'title'   => 'Title',
				'type'    => 'text',
				'default' => 'Credit card',
			),
			'description'    => array(
				'title'   => 'Description',
				'type'    => 'textarea',
				'default' => 'Pay securely with your card.',
			),
			'testmode'       => array(
				'title'   => 'Test mode',
				'type'    => 'checkbox',
				'label'   => 'Use the Acme Pay sandbox',
				'default' => 'yes',
			),
			'test_api_key'   => array( 'title' => 'Sandbox API key', 'type' => 'password' ),
			'api_key'        => array( 'title' => 'Live API key', 'type' => 'password' ),
			'webhook_secret' => array(
				'title'       => 'Webhook secret',
				'type'        => 'password',
				'description' => 'Webhook URL: ' . home_url( '/wc-api/acme_pay/' ),
			),
		);
	}
}

Step 3: Collect card details safely

Never post raw card numbers to your own server. Doing so puts your whole site in PCI scope. Use your provider’s hosted fields or JavaScript library instead. It shows the card inputs inside an iframe and gives you back a one-time token. Your form only carries that token.

	// Inside Acme_Pay_Gateway (classic checkout).
	public function payment_fields() {
		echo wpautop( wp_kses_post( $this->description ) );
		// Acme Pay's script mounts its hosted card fields here and writes the token
		// into the hidden input before the checkout form submits.
		echo '<div id="acme-pay-card"></div>';
		echo '<input type="hidden" name="acme_pay_token" id="acme_pay_token" />';
	}

	public function payment_scripts() {
		if ( ! is_checkout() || 'no' === $this->enabled ) {
			return;
		}
		wp_enqueue_script( 'acme-pay-js', 'https://js.acmepay.example/v1/fields.js', array(), null, true );
		wp_enqueue_script( 'acme-pay-checkout', plugins_url( 'assets/checkout.js', ACME_PAY_FILE ), array( 'jquery', 'acme-pay-js' ), '1.0.0', true );
	}

Add add_action( 'wp_enqueue_scripts', array( $this, 'payment_scripts' ) ); to the constructor so the scripts load on the checkout page.

Step 4: Take the payment in process_payment()

WooCommerce calls process_payment() when the customer places the order. Charge the token, then either mark the order paid and send the customer to the thank-you page, or show an error and stay on checkout.

	public function process_payment( $order_id ) {
		$order = wc_get_order( $order_id );
		$token = isset( $_POST['acme_pay_token'] ) ? sanitize_text_field( wp_unslash( $_POST['acme_pay_token'] ) ) : '';

		if ( ! $token ) {
			wc_add_notice( 'Please check your card details and try again.', 'error' );
			return array( 'result' => 'failure' );
		}

		$response = $this->api( 'POST', '/charges', array(
			'amount'      => (int) round( $order->get_total() * 100 ), // Smallest currency unit.
			'currency'    => $order->get_currency(),
			'source'      => $token,
			'reference'   => $order->get_order_number(),
			'description' => sprintf( 'Order %s', $order->get_order_number() ),
		), 'order-' . $order->get_id() );

		if ( is_wp_error( $response ) ) {
			wc_add_notice( 'We could not reach the payment service. Please try again.', 'error' );
			return array( 'result' => 'failure' );
		}

		if ( 'succeeded' !== ( $response['status'] ?? '' ) ) {
			$order->add_order_note( 'Acme Pay declined: ' . ( $response['message'] ?? 'unknown reason' ) );
			wc_add_notice( 'Your payment was declined. Please try another card.', 'error' );
			return array( 'result' => 'failure' );
		}

		$order->payment_complete( $response['id'] ); // Saves the transaction ID, sets the status.
		WC()->cart->empty_cart();

		return array(
			'result'   => 'success',
			'redirect' => $this->get_return_url( $order ),
		);
	}

Two details matter here. Amounts go to most APIs in the smallest currency unit (cents), but some currencies, such as JPY, have no decimals, so check your provider’s rules. And show customers a plain message while the technical reason goes into the order note.

Step 5: Talk to the API with an idempotency key

One helper keeps every API call consistent. The idempotency key stops a double charge if the request is retried after a timeout. Most modern payment APIs support one.

	private function api( $method, $path, $body = array(), $idempotency_key = '' ) {
		$base    = $this->testmode ? 'https://sandbox.acmepay.example/v1' : 'https://api.acmepay.example/v1';
		$headers = array(
			'Authorization' => 'Bearer ' . $this->api_key,
			'Content-Type'  => 'application/json',
		);
		if ( $idempotency_key ) {
			$headers['Idempotency-Key'] = $idempotency_key;
		}

		$response = wp_remote_request( $base . $path, array(
			'method'  => $method,
			'headers' => $headers,
			'body'    => $body ? wp_json_encode( $body ) : null,
			'timeout' => 45,
		) );

		if ( is_wp_error( $response ) ) {
			wc_get_logger()->error( $response->get_error_message(), array( 'source' => $this->id ) );
			return $response;
		}
		return json_decode( wp_remote_retrieve_body( $response ), true );
	}

Step 6: Add refunds

Because the class declares 'refunds' in $this->supports, WooCommerce shows a “Refund via Acme Pay” button on the order screen and calls process_refund().

	public function process_refund( $order_id, $amount = null, $reason = '' ) {
		$order = wc_get_order( $order_id );
		$charge_id = $order->get_transaction_id();
		if ( ! $charge_id ) {
			return new WP_Error( 'acme_pay_refund', 'This order has no Acme Pay transaction ID.' );
		}

		$response = $this->api( 'POST', '/refunds', array(
			'charge' => $charge_id,
			'amount' => (int) round( $amount * 100 ),
			'reason' => $reason,
		) );

		if ( is_wp_error( $response ) || 'succeeded' !== ( $response['status'] ?? '' ) ) {
			return new WP_Error( 'acme_pay_refund', 'Acme Pay could not process the refund.' );
		}

		$order->add_order_note( sprintf( 'Refunded %s via Acme Pay (%s).', wc_price( $amount ), $response['id'] ) );
		return true;
	}

Step 7: Handle webhooks

Webhooks tell your store about events that happen later, such as a dispute, a delayed capture or a refund made in the provider’s dashboard. WooCommerce routes /wc-api/acme_pay/ to the woocommerce_api_acme_pay action registered in the constructor. Always verify the signature before trusting the payload.

	public function handle_webhook() {
		$payload   = file_get_contents( 'php://input' );
		$signature = isset( $_SERVER['HTTP_X_ACME_SIGNATURE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_ACME_SIGNATURE'] ) ) : '';
		$expected  = hash_hmac( 'sha256', $payload, $this->get_option( 'webhook_secret' ) );

		if ( ! $signature || ! hash_equals( $expected, $signature ) ) {
			status_header( 401 );
			exit;
		}

		$event  = json_decode( $payload, true );
		$orders = wc_get_orders( array(
			'transaction_id' => $event['data']['charge'] ?? '',
			'limit'          => 1,
		) );
		$order = $orders ? $orders[0] : null;

		if ( $order && 'charge.disputed' === $event['type'] ) {
			$order->update_status( 'on-hold', 'Acme Pay: the customer opened a dispute.' );
		}

		status_header( 200 );
		exit;
	}

Make webhook handling idempotent: providers resend events, so check the order’s current state before changing it.

Step 8: Support the block checkout

New WooCommerce stores use the block-based checkout, and a gateway that only implements payment_fields() won’t appear there. Register an integration class on the PHP side and a payment method on the JavaScript side.

<?php
// includes/class-acme-pay-blocks.php
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;

final class Acme_Pay_Blocks extends AbstractPaymentMethodType {
	protected $name = 'acme_pay';

	public function initialize() {
		$this->settings = get_option( 'woocommerce_acme_pay_settings', array() );
	}

	public function is_active() {
		return 'yes' === ( $this->settings['enabled'] ?? 'no' );
	}

	public function get_payment_method_script_handles() {
		wp_register_script( 'acme-pay-blocks', plugins_url( 'assets/blocks.js', ACME_PAY_FILE ),
			array( 'wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-html-entities' ), '1.0.0', true );
		return array( 'acme-pay-blocks' );
	}

	public function get_payment_method_data() {
		return array(
			'title'       => $this->settings['title'] ?? 'Credit card',
			'description' => $this->settings['description'] ?? '',
			'supports'    => array( 'products', 'refunds' ),
		);
	}
}

// In the main plugin file:
add_action( 'woocommerce_blocks_payment_method_type_registration', function ( $registry ) {
	require_once __DIR__ . '/includes/class-acme-pay-blocks.php';
	$registry->register( new Acme_Pay_Blocks() );
} );
// assets/blocks.js
const settings = window.wc.wcSettings.getSetting( 'acme_pay_data', {} );
const title = window.wp.htmlEntities.decodeEntities( settings.title || 'Credit card' );
const Content = () => window.wp.element.createElement( 'div', { id: 'acme-pay-card' } );

window.wc.wcBlocksRegistry.registerPaymentMethod( {
	name: 'acme_pay',
	label: title,
	ariaLabel: title,
	content: window.wp.element.createElement( Content ),
	edit: window.wp.element.createElement( Content ),
	canMakePayment: () => true,
	supports: { features: settings.supports || [] },
} );

In the block checkout, the card token reaches the server through the payment method’s onPaymentSetup event as paymentMethodData, and arrives in process_payment() under the same key in $_POST. Mount the provider’s hosted fields inside Content and return the token from that event handler.

Step 9: Declare HPOS and block compatibility

WooCommerce warns store owners about plugins that haven’t declared compatibility with High-Performance Order Storage. Because the code above only uses wc_get_order() and order methods, never direct post meta, it’s compatible. Say so:

add_action( 'before_woocommerce_init', function () {
	if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', ACME_PAY_FILE, true );
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', ACME_PAY_FILE, true );
	}
} );

Step 10: Test before you ship

  • An approved payment and a declined payment on both classic and block checkout
  • A full refund and a partial refund from the order screen
  • A webhook with a bad signature (it must be rejected) and the same event sent twice
  • A network timeout, by pointing the sandbox URL at a closed port
  • A currency with no decimals, if you sell in one
  • HPOS on and off, under WooCommerce › Settings › Advanced › Features

Where to go from here

This is a solid base. Production gateways usually add saved cards and WooCommerce Subscriptions (the tokenization and subscriptions features), 3D Secure, authorize-and-capture, and debug logging that masks card data. The development guide covers what each involves.

Rather have it built for you? RyanPlugins has built gateways for accept.blue, Cybersource and crypto wallets. See custom payment integrations.

Frequently asked questions

Do I need to be PCI compliant to build a payment gateway?

Every store that takes cards has PCI obligations. If you use your provider’s hosted fields or redirect, card numbers never touch your server, and most stores can use the shortest self-assessment, SAQ A. Posting raw card numbers to your own server puts you in a much larger scope.

Why doesn’t my gateway show on the checkout page?

Check three things: the gateway is enabled in its settings, the class is added to woocommerce_payment_gateways, and, if the store uses the block checkout, a block integration is registered. Without that last one, the gateway only appears on the classic checkout.

How long does it take to build a custom gateway?

A basic gateway like the one in this tutorial takes a day or two. A production gateway with saved cards, subscriptions, 3D Secure, block checkout and thorough testing usually takes several weeks.

Can I sell a gateway plugin I build?

Yes. WooCommerce extensions are GPL-licensed WordPress plugins, and many are sold on marketplaces such as CodeCanyon or on the developer’s own site. Check your payment provider’s terms on using its name and API.

Leave a Reply

Your email address will not be published. Required fields are marked *