
I've integrated over 30 different payment gateways into WooCommerce stores—from standard processors like Stripe and PayPal to regional providers and custom banking solutions. In this guide, I'll walk you through everything you need to know to build a production-ready WooCommerce payment gateway.
Why Build a Custom Payment Gateway?
You might need a custom payment gateway when:
Regional Payment Providers: Your country uses payment processors not supported by existing plugins Custom Business Logic: You need specific payment flows or approval processes Cost Savings: Avoid paying for premium plugins when you can build it yourself Client Requirements: Specific banking integrations or corporate payment systems Learning: Understanding payment gateway architecture makes you a better developer
I recently built a custom gateway for a Middle Eastern e-commerce client integrating with their local bank's API. The solution saved them $500/year in plugin costs and processed $2M+ in transactions.
Understanding WooCommerce Payment Gateway Architecture
WooCommerce payment gateways extend the WC_Payment_Gateway class and hook into the checkout process at specific points:
- Customer selects payment method → Gateway displays payment fields
- Customer submits order → Gateway processes payment
- Payment success/failure → Gateway updates order status
- Admin manages settings → Gateway settings page in WooCommerce
Types of Payment Gateways
Direct/On-Site: Customer enters card details on your site (requires PCI compliance)
- Examples: Stripe Elements, Authorize.Net
- Higher conversion rates
- More development complexity
- Requires SSL certificate
Redirect/Off-Site: Customer redirected to payment provider
- Examples: PayPal Standard, 2Checkout
- Lower conversion rates
- Easier implementation
- Payment provider handles PCI compliance
Server-to-Server: Backend API communication
- Examples: Bank transfers, custom integrations
- Full control over flow
- Requires API credentials
Building a Basic Payment Gateway Plugin
Let's build a complete payment gateway from scratch. We'll create a redirect-based gateway similar to PayPal Standard.
Step 1: Create Plugin Structure
Create folder: wp-content/plugins/custom-payment-gateway/
Main plugin file (custom-payment-gateway.php):
<?php
/**
* Plugin Name: Custom Payment Gateway
* Plugin URI: https://yoursite.com
* Description: Custom payment gateway for WooCommerce
* Version: 1.0.0
* Author: Your Name
* Author URI: https://yoursite.com
* Text Domain: custom-payment-gateway
* Requires at least: 5.8
* Requires PHP: 7.4
* WC requires at least: 6.0
* WC tested up to: 8.5
*/
// Exit if accessed directly
if (!defined('ABSPATH')) {
exit;
}
// Check if WooCommerce is active
if (in_array('woocommerce/woocommerce.php', apply_filters('active_plugins', get_option('active_plugins')))) {
// Initialize the gateway
add_action('plugins_loaded', 'init_custom_payment_gateway');
function init_custom_payment_gateway() {
// Include the gateway class
require_once plugin_dir_path(__FILE__) . 'includes/class-custom-payment-gateway.php';
// Add gateway to WooCommerce
add_filter('woocommerce_payment_gateways', 'add_custom_payment_gateway');
function add_custom_payment_gateway($gateways) {
$gateways[] = 'WC_Custom_Payment_Gateway';
return $gateways;
}
// Add settings link on plugin page
add_filter('plugin_action_links_' . plugin_basename(__FILE__), 'custom_gateway_settings_link');
function custom_gateway_settings_link($links) {
$settings_link = '<a href="' . admin_url('admin.php?page=wc-settings&tab=checkout§ion=custom_payment') . '">Settings</a>';
array_unshift($links, $settings_link);
return $links;
}
}
}
Step 2: Create the Gateway Class
Create file: includes/class-custom-payment-gateway.php
<?php
/**
* Custom Payment Gateway Class
*/
class WC_Custom_Payment_Gateway extends WC_Payment_Gateway {
/**
* Constructor
*/
public function __construct() {
$this->id = 'custom_payment';
$this->icon = '';
$this->has_fields = false; // No payment fields on checkout page
$this->method_title = 'Custom Payment Gateway';
$this->method_description = 'Accept payments via our custom payment processor';
// Load settings
$this->init_form_fields();
$this->init_settings();
// Get 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->testmode ? $this->get_option('test_api_key') : $this->get_option('live_api_key');
$this->api_secret = $this->testmode ? $this->get_option('test_api_secret') : $this->get_option('live_api_secret');
// Hooks
add_action('woocommerce_update_options_payment_gateways_' . $this->id, array($this, 'process_admin_options'));
add_action('woocommerce_api_' . strtolower(get_class($this)), array($this, 'handle_payment_callback'));
}
/**
* Initialize gateway settings form fields
*/
public function init_form_fields() {
$this->form_fields = array(
'enabled' => array(
'title' => 'Enable/Disable',
'type' => 'checkbox',
'label' => 'Enable Custom Payment Gateway',
'default' => 'no'
),
'title' => array(
'title' => 'Title',
'type' => 'text',
'description' => 'Payment method title that customers see during checkout',
'default' => 'Credit Card / Debit Card',
'desc_tip' => true,
),
'description' => array(
'title' => 'Description',
'type' => 'textarea',
'description' => 'Payment method description that customers see during checkout',
'default' => 'Pay securely using your credit or debit card.',
'desc_tip' => true,
),
'testmode' => array(
'title' => 'Test Mode',
'label' => 'Enable Test Mode',
'type' => 'checkbox',
'description' => 'Place the payment gateway in test mode using test API credentials',
'default' => 'yes',
'desc_tip' => true,
),
'test_api_key' => array(
'title' => 'Test API Key',
'type' => 'text',
'description' => 'Get your API credentials from your payment provider dashboard',
'default' => '',
'desc_tip' => true,
),
'test_api_secret' => array(
'title' => 'Test API Secret',
'type' => 'password',
'description' => 'Keep this secret and never share it publicly',
'default' => '',
'desc_tip' => true,
),
'live_api_key' => array(
'title' => 'Live API Key',
'type' => 'text',
'description' => 'Production API key from your payment provider',
'default' => '',
'desc_tip' => true,
),
'live_api_secret' => array(
'title' => 'Live API Secret',
'type' => 'password',
'description' => 'Production API secret - keep this confidential',
'default' => '',
'desc_tip' => true,
),
);
}
/**
* Process payment
*/
public function process_payment($order_id) {
$order = wc_get_order($order_id);
// Create payment request
$payment_url = $this->create_payment_request($order);
if (is_wp_error($payment_url)) {
wc_add_notice($payment_url->get_error_message(), 'error');
return array(
'result' => 'failure'
);
}
// Mark order as pending payment
$order->update_status('pending', 'Customer redirected to payment gateway');
// Reduce stock levels
wc_reduce_stock_levels($order_id);
// Remove cart
WC()->cart->empty_cart();
// Redirect to payment URL
return array(
'result' => 'success',
'redirect' => $payment_url
);
}
/**
* Create payment request and return payment URL
*/
private function create_payment_request($order) {
$api_url = $this->testmode
? 'https://sandbox.payment-provider.com/api/v1/checkout'
: 'https://payment-provider.com/api/v1/checkout';
$callback_url = WC()->api_request_url(get_class($this));
$payment_data = array(
'merchant_id' => $this->api_key,
'amount' => $order->get_total(),
'currency' => $order->get_currency(),
'order_id' => $order->get_id(),
'return_url' => $this->get_return_url($order),
'cancel_url' => $order->get_cancel_order_url(),
'callback_url' => $callback_url,
'customer' => array(
'name' => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(),
'email' => $order->get_billing_email(),
'phone' => $order->get_billing_phone(),
),
'items' => $this->get_order_items($order),
'timestamp' => time(),
);
// Generate signature (HMAC)
$payment_data['signature'] = $this->generate_signature($payment_data);
// Make API request
$response = wp_remote_post($api_url, array(
'method' => 'POST',
'timeout' => 45,
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->api_secret,
),
'body' => json_encode($payment_data),
));
if (is_wp_error($response)) {
$this->log('Payment request failed: ' . $response->get_error_message());
return new WP_Error('api_error', 'Unable to connect to payment provider. Please try again.');
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (isset($body['payment_url'])) {
return $body['payment_url'];
}
$this->log('Invalid API response: ' . print_r($body, true));
return new WP_Error('invalid_response', 'Payment provider returned an error. Please try again.');
}
/**
* Get order items for payment request
*/
private function get_order_items($order) {
$items = array();
foreach ($order->get_items() as $item) {
$items[] = array(
'name' => $item->get_name(),
'quantity' => $item->get_quantity(),
'price' => $item->get_total(),
);
}
return $items;
}
/**
* Generate signature for payment request
*/
private function generate_signature($data) {
// Remove signature field if it exists
unset($data['signature']);
// Sort data alphabetically by key
ksort($data);
// Create string from data
$string = http_build_query($data);
// Generate HMAC SHA256 signature
return hash_hmac('sha256', $string, $this->api_secret);
}
/**
* Handle payment callback from payment provider
*/
public function handle_payment_callback() {
$raw_post = file_get_contents('php://input');
$data = json_decode($raw_post, true);
$this->log('Payment callback received: ' . print_r($data, true));
// Verify signature
if (!$this->verify_callback_signature($data)) {
$this->log('Invalid callback signature');
status_header(403);
exit('Invalid signature');
}
$order_id = isset($data['order_id']) ? intval($data['order_id']) : 0;
$order = wc_get_order($order_id);
if (!$order) {
$this->log('Order not found: ' . $order_id);
status_header(404);
exit('Order not found');
}
// Check if already processed
if ($order->is_paid()) {
status_header(200);
exit('Order already processed');
}
$payment_status = isset($data['status']) ? sanitize_text_field($data['status']) : '';
$transaction_id = isset($data['transaction_id']) ? sanitize_text_field($data['transaction_id']) : '';
switch ($payment_status) {
case 'success':
$order->payment_complete($transaction_id);
$order->add_order_note('Payment completed via Custom Payment Gateway. Transaction ID: ' . $transaction_id);
break;
case 'failed':
$order->update_status('failed', 'Payment failed');
break;
case 'pending':
$order->update_status('on-hold', 'Payment pending confirmation');
break;
default:
$this->log('Unknown payment status: ' . $payment_status);
}
status_header(200);
exit('OK');
}
/**
* Verify callback signature
*/
private function verify_callback_signature($data) {
if (!isset($data['signature'])) {
return false;
}
$received_signature = $data['signature'];
$expected_signature = $this->generate_signature($data);
return hash_equals($expected_signature, $received_signature);
}
/**
* Log messages
*/
private function log($message) {
if ($this->testmode) {
$logger = wc_get_logger();
$logger->debug($message, array('source' => 'custom-payment-gateway'));
}
}
}
Building a Direct Payment Gateway (Stripe-style)
For on-site payments where customers enter card details on your site:
class WC_Direct_Payment_Gateway extends WC_Payment_Gateway {
public function __construct() {
// ... basic setup ...
$this->has_fields = true; // Enable payment fields
$this->supports = array(
'products',
'refunds',
'subscriptions', // If supporting WooCommerce Subscriptions
);
}
/**
* Payment fields on checkout page
*/
public function payment_fields() {
if ($this->description) {
echo wpautop(wp_kses_post($this->description));
}
?>
<fieldset class="wc-payment-form">
<div class="form-row form-row-wide">
<label>Card Number <span class="required">*</span></label>
<input type="text" class="input-text" name="custom_card_number" placeholder="1234 5678 9012 3456" autocomplete="cc-number" />
</div>
<div class="form-row form-row-first">
<label>Expiry Date <span class="required">*</span></label>
<input type="text" class="input-text" name="custom_card_expiry" placeholder="MM / YY" autocomplete="cc-exp" />
</div>
<div class="form-row form-row-last">
<label>CVV <span class="required">*</span></label>
<input type="text" class="input-text" name="custom_card_cvv" placeholder="123" autocomplete="cc-csc" maxlength="4" />
</div>
<div class="clear"></div>
</fieldset>
<?php
}
/**
* Validate payment fields
*/
public function validate_fields() {
if (empty($_POST['custom_card_number'])) {
wc_add_notice('Card number is required', 'error');
return false;
}
if (empty($_POST['custom_card_expiry'])) {
wc_add_notice('Expiry date is required', 'error');
return false;
}
if (empty($_POST['custom_card_cvv'])) {
wc_add_notice('CVV is required', 'error');
return false;
}
// Add more validation (Luhn algorithm for card number, expiry date format, etc.)
return true;
}
/**
* Process payment
*/
public function process_payment($order_id) {
$order = wc_get_order($order_id);
// Get card details (NEVER log or store full card numbers)
$card_number = sanitize_text_field($_POST['custom_card_number']);
$card_expiry = sanitize_text_field($_POST['custom_card_expiry']);
$card_cvv = sanitize_text_field($_POST['custom_card_cvv']);
// Tokenize or encrypt card data before sending to payment API
$payment_token = $this->tokenize_card($card_number, $card_expiry, $card_cvv);
if (is_wp_error($payment_token)) {
wc_add_notice($payment_token->get_error_message(), 'error');
return array('result' => 'failure');
}
// Process payment via API
$result = $this->charge_card($order, $payment_token);
if (is_wp_error($result)) {
wc_add_notice($result->get_error_message(), 'error');
return array('result' => 'failure');
}
// Mark payment complete
$order->payment_complete($result['transaction_id']);
$order->add_order_note('Payment completed. Transaction ID: ' . $result['transaction_id']);
// Reduce stock
wc_reduce_stock_levels($order_id);
// Empty cart
WC()->cart->empty_cart();
return array(
'result' => 'success',
'redirect' => $this->get_return_url($order)
);
}
/**
* Tokenize card (send to payment API to create token)
*/
private function tokenize_card($number, $expiry, $cvv) {
// API call to tokenize card
// NEVER store raw card data
$response = wp_remote_post('https://api.payment-provider.com/tokens', array(
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_secret,
'Content-Type' => 'application/json',
),
'body' => json_encode(array(
'card_number' => $number,
'expiry_date' => $expiry,
'cvv' => $cvv,
))
));
if (is_wp_error($response)) {
return new WP_Error('tokenization_failed', 'Unable to process card');
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (isset($body['token'])) {
return $body['token'];
}
return new WP_Error('invalid_card', 'Card validation failed');
}
/**
* Charge card via API
*/
private function charge_card($order, $token) {
$response = wp_remote_post('https://api.payment-provider.com/charges', array(
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_secret,
'Content-Type' => 'application/json',
),
'body' => json_encode(array(
'token' => $token,
'amount' => $order->get_total() * 100, // Amount in cents
'currency' => $order->get_currency(),
'description' => 'Order #' . $order->get_id(),
'metadata' => array(
'order_id' => $order->get_id(),
'customer_email' => $order->get_billing_email(),
)
))
));
if (is_wp_error($response)) {
return new WP_Error('charge_failed', 'Payment processing failed');
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (isset($body['status']) && $body['status'] === 'succeeded') {
return array(
'transaction_id' => $body['id'],
'status' => 'success'
);
}
return new WP_Error('payment_declined', $body['error_message'] ?? 'Payment was declined');
}
}
Implementing Refunds
Add refund support to your gateway:
public function __construct() {
// ... existing code ...
$this->supports = array('products', 'refunds');
}
/**
* Process refund
*/
public function process_refund($order_id, $amount = null, $reason = '') {
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('invalid_order', 'Order not found');
}
$transaction_id = $order->get_transaction_id();
if (!$transaction_id) {
return new WP_Error('no_transaction', 'No transaction ID found');
}
// Call payment provider API to process refund
$response = wp_remote_post('https://api.payment-provider.com/refunds', array(
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_secret,
'Content-Type' => 'application/json',
),
'body' => json_encode(array(
'transaction_id' => $transaction_id,
'amount' => $amount * 100, // Amount in cents
'reason' => $reason,
))
));
if (is_wp_error($response)) {
return new WP_Error('refund_failed', $response->get_error_message());
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (isset($body['status']) && $body['status'] === 'succeeded') {
$order->add_order_note(sprintf('Refund of %s processed successfully. Refund ID: %s', wc_price($amount), $body['id']));
return true;
}
return new WP_Error('refund_error', $body['error'] ?? 'Refund failed');
}
Subscription Support
For recurring payments (WooCommerce Subscriptions):
public function __construct() {
// ... existing code ...
$this->supports = array(
'products',
'subscriptions',
'subscription_cancellation',
'subscription_suspension',
'subscription_reactivation',
'subscription_amount_changes',
'subscription_date_changes',
'multiple_subscriptions',
);
// Subscription hooks
add_action('woocommerce_scheduled_subscription_payment_' . $this->id, array($this, 'process_subscription_payment'), 10, 2);
}
/**
* Process scheduled subscription payment
*/
public function process_subscription_payment($amount, $order) {
// Get saved payment token for customer
$user_id = $order->get_user_id();
$payment_token = get_user_meta($user_id, '_payment_token', true);
if (!$payment_token) {
$order->update_status('failed', 'No payment method saved for subscription');
return;
}
// Process payment
$result = $this->charge_card($order, $payment_token);
if (is_wp_error($result)) {
$order->update_status('failed', $result->get_error_message());
} else {
$order->payment_complete($result['transaction_id']);
}
}
Security Best Practices
1. PCI Compliance
CRITICAL: If you're handling credit card data directly:
- Never store full card numbers (use tokenization)
- Never log card details
- Use HTTPS (SSL certificate required)
- Validate and sanitize all input
- Follow PCI DSS standards
Recommended: Use hosted payment pages or JavaScript libraries (like Stripe Elements) that handle card data without it touching your server.
2. Validate All Input
// Sanitize and validate
$order_id = absint($_POST['order_id']);
$amount = floatval($_POST['amount']);
$email = sanitize_email($_POST['email']);
// Verify nonces for admin actions
if (!wp_verify_nonce($_POST['_wpnonce'], 'gateway_action')) {
wp_die('Security check failed');
}
3. Verify Callbacks
Always verify payment provider callbacks:
private function verify_callback_signature($data) {
$expected = hash_hmac('sha256', json_encode($data), $this->api_secret);
return hash_equals($expected, $data['signature']);
}
4. Use WordPress HTTP API
Never use file_get_contents() or cURL directly. Use WordPress HTTP API:
$response = wp_remote_post($url, array(
'timeout' => 30,
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key
),
'body' => $data
));
if (is_wp_error($response)) {
// Handle error
}
Testing Your Gateway
Test Mode Setup
Always include test mode:
$this->testmode = 'yes' === $this->get_option('testmode');
$this->api_url = $this->testmode
? 'https://sandbox.provider.com/api'
: 'https://provider.com/api';
Test Checklist
✅ Successful payment ✅ Failed payment ✅ Pending payment ✅ Payment callback handling ✅ Refund processing ✅ Error handling ✅ Order status updates ✅ Customer notifications ✅ Admin notifications ✅ Security (signature verification) ✅ Logging (test mode only)
Test Cards
Most payment providers offer test card numbers:
Visa: 4242424242424242 Mastercard: 5555555555554444 Declined: 4000000000000002
Common Issues
Issue 1: Callback URL Not Working
Solution: Check permalink structure
// Callback URL format
$callback_url = WC()->api_request_url(get_class($this));
// Results in: https://yoursite.com/?wc-api=wc_custom_payment_gateway
Issue 2: Order Status Not Updating
Cause: Callback signature verification failing
Solution: Log callback data and verify signature algorithm matches provider documentation
Issue 3: Payment Stuck in Pending
Cause: Callback not received or processed
Solution: Check logs, verify callback URL is publicly accessible, check for PHP errors
Frequently Asked Questions
Do I need PCI compliance for a redirect gateway?
No, if customers are redirected to the payment provider's site and never enter card details on your site.
Can I test without a payment provider account?
Yes, create a dummy gateway that simulates payment flow without actual API calls.
How do I add custom fields to checkout?
Use payment_fields() method to display custom HTML on checkout page.
Should I use a plugin or build custom?
Use plugin if:
- Provider has official WooCommerce plugin
- You need quick setup
- No custom requirements
Build custom if:
- Provider has no plugin
- You need specific customization
- You're integrating with custom system
Next Steps
Now you can:
- Integrate popular providers: Stripe, PayPal, Square
- Build regional gateways: Local bank integrations
- Add subscription support: Recurring payments
- Implement saved cards: Tokenization for repeat customers
- Add Apple Pay/Google Pay: Digital wallet support
Resources
Need help integrating a custom payment gateway? I've built 30+ WooCommerce payment integrations for clients worldwide. Contact me for custom development.

Fysal Yaqoob
Expert WordPress & Shopify Developer
Senior full-stack developer with 10+ years experience specializing in WordPress, Shopify, and headless CMS solutions. Delivering custom themes, plugins, e-commerce stores, and scalable web applications.
Practice: Code Typer
Master WooCommerce development by practicing with real code snippets in Code Typer!


