
Key takeaways
- Developing enterprise WooCommerce payment gateways requires strict adherence to PCI-DSS SAQ-A compliance using client-side iframe tokenization to ensure raw PAN data never touches the PHP origin server.
- Modern gateways must explicitly declare compatibility with WooCommerce High-Performance Order Storage (HPOS) and React-powered Cart & Checkout Blocks.
- Passing unique `Idempotency-Key` headers on all upstream payment API requests prevents catastrophic double-charging during network timeouts or user button double-clicks.
- Always handle payment status transitions asynchronously via cryptographically signed webhooks (HMAC SHA-256) rather than relying exclusively on synchronous customer browser redirects.
- Process all monetary amounts in integer minor currency units, properly handling standard, 3-decimal (e.g. KWD, BHD), and zero-decimal currencies (e.g. JPY, KRW) to eliminate floating-point rounding errors.
- Seamlessly support WooCommerce Subscriptions by handling automatic recurring renewals via `scheduled_subscription_payment` action hooks and `WC_Payment_Token_CC` persistence.
- Implement automated PHPUnit integration tests mocking the payment processor API via the `pre_http_request` filter to verify tokenization, declines, and partial refund lifecycles.
WooCommerce powers over 30% of all online stores globally, handling billions of dollars in annual transaction volume. While off-the-shelf payment plugins exist for mainstream processors like Stripe and PayPal, enterprise e-commerce merchants frequently require custom payment gateways. Whether integrating regional payment networks (such as Pix in Brazil, UPI in India, or iDEAL in the Netherlands), building proprietary B2B invoice credit rails, or connecting to custom private-banking escrows, engineering a custom gateway requires flawless reliability, robust security, and deep architectural rigor.
A single defect in payment processing code can cause devastating consequences: duplicate customer credit card charges, unhandled 3D Secure (3DS2) authentication drops, missing webhook order reconciliations, and severe PCI-DSS non-compliance penalties. Furthermore, modern WooCommerce requires full compatibility with High-Performance Order Storage (HPOS / Custom Order Tables) and the Gutenberg React Checkout Block.
In this definitive engineering masterclass, we will construct an enterprise-grade, PSR-4 compliant custom WooCommerce payment gateway from scratch. We will analyze the complete payment lifecycle, implement client-side tokenization for PCI-DSS SAQ-A compliance, handle 3DS2 SCA authentication, support WooCommerce Subscriptions recurring renewals, manage multi-currency minor unit precision (including zero-decimal currencies), manage saved payment tokens (`WC_Payment_Token_CC`), build a resilient HMAC webhook reconciliation controller, implement partial and full refunds, build React Checkout Block bindings, write automated PHPUnit test suites, and benchmark origin resilience using k6 load tests.
The Anatomy of a WooCommerce Payment Lifecycle
Processing a payment in WooCommerce is a multi-step distributed transaction involving the visitor’s browser, the WordPress application server, the WooCommerce HPOS database, and the upstream Payment Service Provider (PSP) API:
- Checkout Submission: The customer enters card details into a hosted iframe or secure JavaScript element. The PSP’s client SDK tokenizes the card and returns a one-time token (e.g.
tok_1N4k...) to the frontend form. - Server-Side Processing (`process_payment`): WooCommerce creates a pending order in HPOS and calls the gateway’s
process_payment($order_id)method. - Upstream API Request: The gateway sends a server-to-server POST request to the PSP with the payment token, order total (in integer cents), and a unique
Idempotency-Keyheader. - 3D Secure 2 (3DS2) Challenge: If the customer’s issuing bank requires Strong Customer Authentication (SCA), the gateway returns a redirect URL to the 3DS2 challenge iframe/modal.
- Asynchronous Webhook Reconciliation: When the charge succeeds, the PSP sends an asynchronous HTTP POST webhook to WordPress. The webhook controller validates the HMAC signature, locates the order, records the transaction ID, and invokes
$order->payment_complete(). - Order Finalization: Stock is reduced, transactional emails are dispatched, and the customer is redirected to the Thank You / Order Received page.
PCI-DSS Compliance and Security Architecture
When building custom payment gateways, security architecture dictates regulatory burden. Under the Payment Card Industry Data Security Standard (PCI-DSS):
| Integration Approach | PCI-DSS Scope | Audit Requirements | Security Risk |
|---|---|---|---|
| Direct POST to WordPress Server | SAQ D (Full Scope) | Annual on-site audit, quarterly vulnerability scans, severe liability. | Extreme: Server compromise exposes all customer credit card PANs. |
| Hosted Checkout Redirect | SAQ A | Self-Assessment Questionnaire (14 questions). | Low: Customer leaves site; potential conversion drop-off. |
| Client-Side Iframe / Tokenization (Recommended) | SAQ A-EP / SAQ A | Lightweight Self-Assessment Questionnaire. | Minimal: Card data goes directly from browser to PSP; WordPress never touches PAN. |
We strictly implement the Client-Side Tokenization Architecture, ensuring our PHP codebase only ever handles opaque payment tokens (e.g. pm_1N4kXYZ).
Multi-Currency Precision and Zero-Decimal Currencies
A critical defect in naive payment gateway code is assuming that all currencies have two decimal places (e.g. multiplying by 100). In international e-commerce, currencies follow three distinct formatting rules:
- Standard Two-Decimal Currencies (Multiplier: 100): USD, EUR, GBP, AUD, CAD, INR. (e.g. $49.99 ->
4999). - Zero-Decimal Currencies (Multiplier: 1): JPY (Japanese Yen), KRW (South Korean Won), VND (Vietnamese Dong), CLP (Chilean Peso). (e.g. ¥5000 ->
5000). Multiplying JPY by 100 would overcharge the customer by 100x! - Three-Decimal Currencies (Multiplier: 1000): KWD (Kuwaiti Dinar), BHD (Bahraini Dinar), OMR (Omani Rial). (e.g. 15.250 KWD ->
15250).
Currency Normalizer Service
<?php
declare(strict_types=1);
namespace WPStackEnterprisePluginServices;
class CurrencyMinorUnitNormalizer {
private const ZERO_DECIMAL_CURRENCIES = [
'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW',
'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF',
];
private const THREE_DECIMAL_CURRENCIES = [
'BHD', 'JOD', 'KWD', 'OMR', 'TND',
];
/**
* Convert floating-point order total to integer minor units.
*
* @param float $amount
* @param string $currency ISO-4217 Currency Code
* @return int
*/
public static function toMinorUnits(float $amount, string $currency): int {
$upperCurrency = strtoupper($currency);
if (in_array($upperCurrency, self::ZERO_DECIMAL_CURRENCIES, true)) {
return (int) round($amount);
}
if (in_array($upperCurrency, self::THREE_DECIMAL_CURRENCIES, true)) {
return (int) round($amount * 1000);
}
// Standard 2-decimal currencies
return (int) round($amount * 100);
}
}Building the PSR-4 Custom Payment Gateway Class
We encapsulate our custom gateway inside a clean PSR-4 class extending WC_Payment_Gateway, declaring support for products, refunds, tokenization, subscriptions, and HPOS:
<?php
declare(strict_types=1);
namespace WPStackEnterprisePluginGateways;
use WC_Payment_Gateway;
use WC_Order;
use WC_Payment_Token_CC;
use WPStackEnterprisePluginServicesCurrencyMinorUnitNormalizer;
use Exception;
use Throwable;
class EnterpriseCustomGateway extends WC_Payment_Gateway {
public const GATEWAY_ID = 'wpstack_enterprise_gateway';
public function __construct() {
$this->id = self::GATEWAY_ID;
$this->icon = apply_filters('woocommerce_custom_gateway_icon', '');
$this->has_fields = true;
$this->method_title = esc_html__('WPStack Enterprise Gateway', 'wpstack-enterprise');
$this->method_description = esc_html__('High-performance custom payment gateway with 3DS2 and HPOS compatibility.', 'wpstack-enterprise');
// Declare gateway capabilities
$this->supports = [
'products',
'refunds',
'tokenization',
'add_payment_method',
'subscriptions',
'subscription_cancellation',
'subscription_suspension',
'subscription_reactivation',
'subscription_amount_changes',
'subscription_date_changes',
'multiple_subscriptions',
];
// Load configuration settings
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option('title', 'Credit / Debit Card (Enterprise Pay)');
$this->description = $this->get_option('description', 'Pay securely using our encrypted payment gateway.');
$this->enabled = $this->get_option('enabled', 'no');
// Save admin settings hook
add_action("woocommerce_update_options_payment_gateways_{$this->id}", [$this, 'process_admin_options']);
// Frontend asset enqueuing
add_action('wp_enqueue_scripts', [$this, 'enqueuePaymentScripts']);
// Subscription recurring payment hook
add_action('woocommerce_scheduled_subscription_payment_' . $this->id, [$this, 'scheduled_subscription_payment'], 10, 2);
}
public function init_form_fields(): void {
$this->form_fields = [
'enabled' => [
'title' => esc_html__('Enable/Disable', 'wpstack-enterprise'),
'type' => 'checkbox',
'label' => esc_html__('Enable WPStack Enterprise Gateway', 'wpstack-enterprise'),
'default' => 'no',
],
'title' => [
'title' => esc_html__('Title', 'wpstack-enterprise'),
'type' => 'text',
'description' => esc_html__('Payment method title displayed to customers during checkout.', 'wpstack-enterprise'),
'default' => esc_html__('Credit / Debit Card', 'wpstack-enterprise'),
'desc_tip' => true,
],
'api_endpoint' => [
'title' => esc_html__('API Endpoint URL', 'wpstack-enterprise'),
'type' => 'text',
'default' => 'https://api.gateway.wpstack.online/v1',
],
'secret_key' => [
'title' => esc_html__('API Secret Key', 'wpstack-enterprise'),
'type' => 'password',
'description' => esc_html__('Private merchant secret key provided by your payment processor.', 'wpstack-enterprise'),
],
'webhook_secret' => [
'title' => esc_html__('Webhook HMAC Secret', 'wpstack-enterprise'),
'type' => 'password',
'description' => esc_html__('HMAC SHA-256 secret used to verify webhook signatures.', 'wpstack-enterprise'),
],
];
}
public function payment_fields(): void {
if ($this->description) {
echo '<p class="wpstack-gateway-desc">' . wp_kses_post($this->description) . '</p>';
}
// Render client-side secure tokenization element container
echo '<div id="wpstack-card-element" class="wpstack-payment-element" data-gateway-id="' . esc_attr($this->id) . '"></div>';
echo '<input type="hidden" id="wpstack_payment_token" name="wpstack_payment_token" value="" />';
}
public function enqueuePaymentScripts(): void {
if (!is_checkout() && !is_add_payment_method_page()) {
return;
}
wp_enqueue_script(
'wpstack-gateway-sdk',
'https://js.gateway.wpstack.online/v1/sdk.js',
[],
'1.0.0',
true
);
wp_enqueue_script(
'wpstack-checkout-handler',
plugin_dir_url(__DIR__) . 'assets/js/checkout-handler.js',
['jquery', 'wpstack-gateway-sdk'],
'1.0.0',
true
);
wp_localize_script('wpstack-checkout-handler', 'wpstackGatewayConfig', [
'publishableKey' => $this->get_option('publishable_key'),
'gatewayId' => $this->id,
]);
}
/**
* Process checkout payment submission.
*
* @param int $order_id
* @return array{result: string, redirect: string}
* @throws Exception
*/
public function process_payment($order_id): array {
$order = wc_get_order($order_id);
if (!$order instanceof WC_Order) {
throw new Exception(esc_html__('Invalid order reference.', 'wpstack-enterprise'));
}
$paymentToken = sanitize_text_field($_POST['wpstack_payment_token'] ?? '');
if (empty($paymentToken)) {
wc_add_notice(esc_html__('Payment authorization token missing. Please re-enter card details.', 'wpstack-enterprise'), 'error');
return ['result' => 'failure', 'redirect' => ''];
}
$currency = $order->get_currency();
$amountInMinorUnits = CurrencyMinorUnitNormalizer::toMinorUnits((float) $order->get_total(), $currency);
$idempotencyKey = "order_{$order_id}_" . wp_hash($order->get_order_key() . $amountInMinorUnits);
$payload = [
'amount' => $amountInMinorUnits,
'currency' => strtolower($currency),
'payment_token' => $paymentToken,
'order_id' => (string) $order_id,
'customer_email' => $order->get_billing_email(),
'return_url' => $this->get_return_url($order),
];
try {
$response = $this->executeChargeRequest($payload, $idempotencyKey);
if ($response['status'] === 'requires_action' && !empty($response['redirect_url'])) {
// 3D Secure Authentication Challenge Required
$order->update_meta_data('_wpstack_charge_id', sanitize_text_field($response['charge_id']));
$order->save();
return [
'result' => 'success',
'redirect' => $response['redirect_url'],
];
}
if ($response['status'] === 'succeeded') {
$chargeId = sanitize_text_field($response['charge_id']);
$order->payment_complete($chargeId);
$order->add_order_note(sprintf(
esc_html__('WPStack Payment Authorized. Charge ID: %s (Amount: %s %s)', 'wpstack-enterprise'),
$chargeId,
$order->get_total(),
$currency
));
// Save token for future recurring orders if requested
if (!empty($response['customer_token_id']) && $order->get_user_id() > 0) {
$this->savePaymentToken($order->get_user_id(), $response);
}
// Clear customer cart
WC()->cart->empty_cart();
return [
'result' => 'success',
'redirect' => $this->get_return_url($order),
];
}
wc_add_notice($response['error_message'] ?? esc_html__('Payment declined by card issuer.', 'wpstack-enterprise'), 'error');
return ['result' => 'failure', 'redirect' => ''];
} catch (Throwable $e) {
error_log('Gateway Payment Exception: ' . $e->getMessage());
wc_add_notice(esc_html__('A system error occurred while processing payment. Please try again.', 'wpstack-enterprise'), 'error');
return ['result' => 'failure', 'redirect' => ''];
}
}
/**
* Process automated recurring renewal payments for WooCommerce Subscriptions.
*
* @param float $amount
* @param WC_Order $order
*/
public function scheduled_subscription_payment(float $amount, WC_Order $order): void {
$userId = $order->get_user_id();
$tokens = WC_Payment_Tokens::get_customer_tokens($userId, $this->id);
if (empty($tokens)) {
$order->update_status('failed', esc_html__('Recurring payment failed: No saved payment token found for customer.', 'wpstack-enterprise'));
return;
}
/** @var WC_Payment_Token_CC $token */
$token = reset($tokens);
$amountInMinorUnits = CurrencyMinorUnitNormalizer::toMinorUnits($amount, $order->get_currency());
$idempotencyKey = "renewal_{$order->get_id()}_{$amountInMinorUnits}_" . time();
$payload = [
'amount' => $amountInMinorUnits,
'currency' => strtolower($order->get_currency()),
'customer_token' => $token->get_token(),
'order_id' => (string) $order->get_id(),
];
try {
$response = $this->executeChargeRequest($payload, $idempotencyKey);
if ($response['status'] === 'succeeded') {
$order->payment_complete($response['charge_id']);
$order->add_order_note(sprintf(
esc_html__('Subscription Renewal Succeeded. Charge ID: %s', 'wpstack-enterprise'),
$response['charge_id']
));
} else {
$order->update_status('failed', $response['error_message'] ?? 'Recurring charge declined');
}
} catch (Throwable $e) {
$order->update_status('failed', 'Subscription renewal exception: ' . $e->getMessage());
}
}
private function savePaymentToken(int $userId, array $responseData): void {
$token = new WC_Payment_Token_CC();
$token->set_token($responseData['customer_token_id']);
$token->set_gateway_id($this->id);
$token->set_card_type($responseData['card_brand'] ?? 'visa');
$token->set_last4($responseData['last4'] ?? '4242');
$token->set_expiry_month($responseData['exp_month'] ?? '12');
$token->set_expiry_year($responseData['exp_year'] ?? '2030');
$token->set_user_id($userId);
$token->save();
}
/**
* Send HTTP POST charge to upstream payment API with Idempotency Key.
*
* @param array<string, mixed> $payload
* @param string $idempotencyKey
* @return array<string, mixed>
* @throws Exception
*/
private function executeChargeRequest(array $payload, string $idempotencyKey): array {
$endpoint = rtrim($this->get_option('api_endpoint'), '/') . '/charges';
$secretKey = $this->get_option('secret_key');
$args = [
'method' => 'POST',
'timeout' => 15,
'redirection' => 0,
'headers' => [
'Authorization' => 'Bearer ' . $secretKey,
'Content-Type' => 'application/json',
'Idempotency-Key' => $idempotencyKey,
'User-Agent' => 'WPStack-WooCommerce-Gateway/2.0',
],
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
];
$response = wp_remote_post($endpoint, $args);
if (is_wp_error($response)) {
throw new Exception('Payment API Connection Failure: ' . $response->get_error_message());
}
$statusCode = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if ($statusCode >= 400 || !is_array($data)) {
throw new Exception($data['message'] ?? "Upstream API error HTTP {$statusCode}");
}
return $data;
}
/**
* Process automated refunds from WooCommerce Admin dashboard.
*
* @param int $order_id
* @param float|null $amount
* @param string $reason
* @return bool
*/
public function process_refund($order_id, $amount = null, $reason = ''): bool {
$order = wc_get_order($order_id);
if (!$order instanceof WC_Order || $amount === null || $amount <= 0) {
return false;
}
$chargeId = $order->get_transaction_id() ?: $order->get_meta('_wpstack_charge_id');
if (empty($chargeId)) {
$order->add_order_note(esc_html__('Refund failed: No transaction ID on record.', 'wpstack-enterprise'));
return false;
}
$amountInMinorUnits = CurrencyMinorUnitNormalizer::toMinorUnits($amount, $order->get_currency());
$endpoint = rtrim($this->get_option('api_endpoint'), '/') . "/charges/{$chargeId}/refunds";
$secretKey = $this->get_option('secret_key');
$args = [
'method' => 'POST',
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . $secretKey,
'Content-Type' => 'application/json',
'Idempotency-Key' => "refund_{$order_id}_{$amountInMinorUnits}_" . time(),
],
'body' => json_encode([
'amount' => $amountInMinorUnits,
'reason' => sanitize_text_field($reason ?: 'Requested by merchant'),
], JSON_THROW_ON_ERROR),
];
$response = wp_remote_post($endpoint, $args);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
$order->add_order_note(esc_html__('Upstream API rejected refund request.', 'wpstack-enterprise'));
return false;
}
$order->add_order_note(sprintf(
esc_html__('Refund of %s successfully processed via WPStack Gateway.', 'wpstack-enterprise'),
wc_price($amount, ['currency' => $order->get_currency()])
));
return true;
}
}Cryptographic HMAC Webhook Reconciliation
Because customer browser connections can drop immediately after completing payment (e.g. closing mobile Safari or losing 5G connectivity before the redirect fires), synchronous order completion is fragile. Enterprise payment architectures rely on Asynchronous Cryptographically Verified Webhooks.
The REST API Webhook Controller
<?php
declare(strict_types=1);
namespace WPStackEnterprisePluginRest;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WC_Order;
class PaymentWebhookRestController extends WP_REST_Controller {
public function __construct() {
$this->namespace = 'wpstack/v1';
$this->rest_base = 'payment-webhook';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->rest_base, [
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handleWebhook'],
'permission_callback' => '__return_true', // Validated via HMAC signature
],
]);
}
public function handleWebhook(WP_REST_Request $request): WP_REST_Response|WP_Error {
$signature = $request->get_header('x-wpstack-signature') ?? '';
$timestamp = (int) ($request->get_header('x-wpstack-timestamp') ?? 0);
$rawPayload = $request->get_body();
// 1. Replay attack prevention: Reject requests older than 300 seconds
if (abs(time() - $timestamp) > 300) {
return new WP_Error('webhook_timestamp_out_of_bounds', 'Webhook request expired.', ['status' => 400]);
}
// 2. Validate HMAC SHA-256 signature
$options = get_option('woocommerce_wpstack_enterprise_gateway_settings', []);
$webhookSecret = $options['webhook_secret'] ?? '';
if (empty($webhookSecret)) {
return new WP_Error('webhook_misconfigured', 'Webhook secret missing on server.', ['status' => 500]);
}
$signedString = "{$timestamp}.{$rawPayload}";
$expectedSignature = hash_hmac('sha256', $signedString, $webhookSecret);
if (!hash_equals($expectedSignature, $signature)) {
return new WP_Error('invalid_signature', 'Cryptographic HMAC signature mismatch.', ['status' => 401]);
}
// 3. Process event
$data = json_decode($rawPayload, true);
$eventType = $data['event'] ?? '';
$orderId = (int) ($data['data']['order_id'] ?? 0);
$chargeId = sanitize_text_field($data['data']['charge_id'] ?? '');
$order = wc_get_order($orderId);
if (!$order instanceof WC_Order) {
return new WP_REST_Response(['status' => 'ignored', 'reason' => 'order_not_found'], 200);
}
switch ($eventType) {
case 'charge.succeeded':
if (!$order->is_paid()) {
$order->payment_complete($chargeId);
$order->add_order_note(sprintf(
esc_html__('Webhook Confirmation: Payment verified via HMAC webhook (Charge: %s).', 'wpstack-enterprise'),
$chargeId
));
}
break;
case 'charge.failed':
$order->update_status('failed', esc_html__('Webhook: Payment failed or declined by issuer.', 'wpstack-enterprise'));
break;
case 'charge.disputed':
$order->update_status('on-hold', esc_html__('Webhook Alert: Chargeback or dispute opened by customer.', 'wpstack-enterprise'));
break;
}
return new WP_REST_Response(['status' => 'processed'], 200);
}
}When designing custom financial integrations or leveraging custom WooCommerce plugin development, cryptographically verified webhooks guarantee zero lost orders, even if customer network connections disconnect during checkout.
WooCommerce Checkout Blocks Integration
WooCommerce has transitioned its default checkout experience to Gutenberg React Checkout Blocks. To support Checkout Blocks, gateways must register a JavaScript payment method definition via @woocommerce/blocks-registry:
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import { decodeEntities } from '@wordpress/html-entities';
import { getSetting } from '@woocommerce/settings';
const settings = getSetting('wpstack_enterprise_gateway_data', {});
const defaultLabel = 'Credit / Debit Card (Enterprise Pay)';
const label = decodeEntities(settings.title || defaultLabel);
const Content = (props) => {
const { eventRegistration, emitResponse } = props;
const { onPaymentSetup } = eventRegistration;
// Hook into Block Checkout submission event
React.useEffect(() => {
const unsubscribe = onPaymentSetup(async () => {
try {
// Execute client-side tokenization via PSP SDK
const token = await window.WPStackSDK.tokenize();
return {
type: emitResponse.responseTypes.SUCCESS,
meta: {
paymentMethodData: {
wpstack_payment_token: token,
},
},
};
} catch (error) {
return {
type: emitResponse.responseTypes.ERROR,
message: error.message || 'Tokenization failed',
};
}
});
return () => unsubscribe();
}, [onPaymentSetup, emitResponse]);
return (
<div className="wpstack-blocks-card-container">
<div id="wpstack-block-element"></div>
</div>
);
};
registerPaymentMethod({
name: 'wpstack_enterprise_gateway',
label: label,
content: <Content />,
edit: <Content />,
canMakePayment: () => true,
ariaLabel: label,
supports: {
features: settings.supports || ['products'],
},
});Production Incident Case Study: Mitigating Double-Charging Race Condition
An omnichannel apparel retailer running a custom gateway on WooCommerce experienced a severe billing anomaly during a Black Friday flash sale: 418 customers were billed twice for the same order within 300 milliseconds.
1. Telemetry and Root Cause
- Frontend Double-Clicking: Impatient mobile shoppers tapped the “Place Order” button multiple times. The checkout JavaScript lacked an immediate UI disable state, submitting two parallel AJAX requests to
/wp-json/wc/store/v1/checkout. - Missing Idempotency Headers: The gateway’s
executeChargeRequest()method generated non-idempotent HTTP POST requests. The upstream processor treated both concurrent requests as unique transactions, billing the card twice.
Pre-Authorization, Delayed Capture, and Void Lifecycles
High-value merchants, physical fulfillment centers, and B2B wholesale portals frequently require a two-stage transaction workflow: Authorization at checkout to verify funds and hold the customer’s balance, followed by Capture when the physical shipment departs the warehouse or digital service provision commences. In WooCommerce, handling this two-stage lifecycle requires explicit support for capture and void actions from both the admin dashboard and automated fulfillment webhooks.
1. Authorization vs. Capture Architecture
In a direct capture flow (capture: true), the gateway debits the card immediately and transitions the order to processing. In a pre-authorization flow (capture: false), the gateway places an authorization hold (typically valid for 7 to 30 days depending on the card network) and transitions the order to on-hold with custom status meta _wpstack_charge_status = 'authorized'.
<?php
declare(strict_types=1);
namespace WPStackEnterprisePluginServices;
use WC_Order;
use Exception;
class PaymentCaptureService {
private string $apiEndpoint;
private string $secretKey;
public function __construct(string $apiEndpoint, string $secretKey) {
$this->apiEndpoint = $apiEndpoint;
$this->secretKey = $secretKey;
}
/**
* Captures a previously authorized charge upon order fulfillment.
*/
public function captureAuthorizedPayment(WC_Order $order, ?int $amountMinorUnits = null): bool {
$chargeId = (string) $order->get_meta('_wpstack_charge_id', true);
$status = (string) $order->get_meta('_wpstack_charge_status', true);
if (empty($chargeId) || $status !== 'authorized') {
throw new Exception("Order #{$order->get_id()} does not possess an active pre-authorization hold.");
}
$currency = $order->get_currency();
$totalMinorUnits = $amountMinorUnits ?? CurrencyMinorUnitNormalizer::toMinorUnits((float) $order->get_total(), $currency);
$response = wp_remote_post("{$this->apiEndpoint}/charges/{$chargeId}/capture", [
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . $this->secretKey,
'Content-Type' => 'application/json',
'Idempotency-Key'=> "capture_{$order->get_id()}_{$chargeId}",
],
'body' => json_encode([
'amount' => $totalMinorUnits,
]),
]);
if (is_wp_error($response)) {
$order->add_order_note("Capture Failed: " . $response->get_error_message());
return false;
}
$statusCode = wp_remote_retrieve_response_code($response);
$data = json_decode(wp_remote_retrieve_body($response), true);
if ($statusCode !== 200 || ($data['status'] ?? '') !== 'succeeded') {
$errorMessage = $data['error_message'] ?? 'Payment capture declined by processor.';
$order->add_order_note("Capture Declined: {$errorMessage}");
return false;
}
$order->update_meta_data('_wpstack_charge_status', 'captured');
$order->update_meta_data('_wpstack_captured_amount', $totalMinorUnits);
$order->payment_complete($chargeId);
$order->add_order_note(sprintf(
"Pre-authorization captured successfully via API. Captured: %s %s (Transaction ID: %s)",
wc_price(CurrencyMinorUnitNormalizer::fromMinorUnits($totalMinorUnits, $currency)),
$currency,
$chargeId
));
$order->save();
return true;
}
/**
* Voids / cancels an authorized hold without debiting customer funds.
*/
public function voidAuthorization(WC_Order $order): bool {
$chargeId = (string) $order->get_meta('_wpstack_charge_id', true);
if (empty($chargeId)) {
return false;
}
$response = wp_remote_post("{$this->apiEndpoint}/charges/{$chargeId}/void", [
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . $this->secretKey,
'Idempotency-Key'=> "void_{$order->get_id()}_{$chargeId}",
],
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
$order->add_order_note("Failed to void authorization on upstream processor.");
return false;
}
$order->update_meta_data('_wpstack_charge_status', 'voided');
$order->update_status('cancelled', 'Authorization hold successfully voided prior to capture.');
$order->save();
return true;
}
}Enterprise Secret Vault Key Rotation & Network Security
Statically storing payment API secret keys in the WordPress database (wp_options table) exposes payment rails to database snapshot leaks, backup exfiltration, and local file inclusion vectors. In high-security architectures, payment gateway credentials must be fetched from environment secrets or external secret stores (such as AWS Secrets Manager, HashiCorp Vault, or Doppler) and rotated without service interruption.
1. Secure Credential Resolution Flow
Rather than binding configuration fields directly to raw database options, the gateway delegates secret resolution to an environment-aware vault adapter:
<?php
declare(strict_types=1);
namespace WPStackEnterprisePluginSecurity;
class GatewaySecretProvider {
/**
* Resolves active secret key with environment priority over database fallback.
*/
public static function getSecretKey(string $environment = 'production'): string {
$envKey = getenv('WPSTACK_PAYMENT_SECRET_KEY');
if (!empty($envKey)) {
return trim($envKey);
}
if (defined('WPSTACK_PAYMENT_SECRET_KEY') && !empty(WPSTACK_PAYMENT_SECRET_KEY)) {
return WPSTACK_PAYMENT_SECRET_KEY;
}
$options = get_option('woocommerce_wpstack_enterprise_gateway_settings', []);
return $options['secret_key'] ?? '';
}
/**
* Resolves webhook signing secrets supporting dual-key graceful rollover windows.
*/
public static function getWebhookSecrets(): array {
$primary = getenv('WPSTACK_WEBHOOK_SECRET_PRIMARY') ?: '';
$secondary = getenv('WPSTACK_WEBHOOK_SECRET_SECONDARY') ?: '';
$secrets = array_filter([$primary, $secondary]);
if (!empty($secrets)) {
return $secrets;
}
$options = get_option('woocommerce_wpstack_enterprise_gateway_settings', []);
return array_filter([$options['webhook_secret'] ?? '']);
}
}By allowing dual webhook signing secrets (PRIMARY and SECONDARY), developers can rotate webhook credentials in production with zero dropped events: the gateway validates against the primary key first, falling back to the secondary key during the transition window before the legacy key is revoked in the payment provider console.
WooCommerce Subscriptions Dunning & Retry Engine
For SaaS platforms and membership sites using WooCommerce Subscriptions, renewal transaction failures (due to expired cards, temporary bank holds, or insufficient funds) must not immediately cancel customer subscriptions. An enterprise payment gateway must integrate with WooCommerce Subscriptions automatic retry rules and trigger smart dunning workflows.
When a renewal fails in process_subscription_payment(), the gateway returns a failure status and records failure codes. WooCommerce Subscriptions queues the order into the retry engine, scheduling exponential retry attempts at 12 hours, 48 hours, and 120 hours while dispatching customer email notifications to update their billing credentials. When the customer updates their card on the /my-account/payment-methods/ screen, the gateway seamlessly attaches the new WC_Payment_Token_CC to all active subscriptions.
Automated Testing with PHPUnit
We test the complete payment processing lifecycle, 3DS2 redirection, and partial refund handling using WP_UnitTestCase and HTTP request interceptors:
<?php
declare(strict_types=1);
namespace WPStackEnterprisePluginTestsIntegration;
use WP_UnitTestCase;
use WC_Helper_Order;
use WPStackEnterprisePluginGatewaysEnterpriseCustomGateway;
class Test_EnterpriseGatewayIntegration extends WP_UnitTestCase {
private EnterpriseCustomGateway $gateway;
public function setUp(): void {
parent::setUp();
$this->gateway = new EnterpriseCustomGateway();
$this->gateway->update_option('enabled', 'yes');
$this->gateway->update_option('secret_key', 'sk_test_secret_12345');
$this->gateway->update_option('api_endpoint', 'https://api.gateway.mock/v1');
}
public function test_successful_charge_completes_order(): void {
// 1. Create mock WooCommerce order
$order = WC_Helper_Order::create_order();
$order->set_total('99.00');
$order->save();
$_POST['wpstack_payment_token'] = 'tok_valid_test_token';
// 2. Intercept outgoing HTTP API call
add_filter('pre_http_request', static function ($preempt, $args, $url) {
return [
'response' => ['code' => 200, 'message' => 'OK'],
'body' => json_encode([
'status' => 'succeeded',
'charge_id' => 'ch_test_998877',
]),
];
}, 10, 3);
// 3. Execute process_payment
$result = $this->gateway->process_payment($order->get_id());
$this->assertSame('success', $result['result']);
$this->assertNotEmpty($result['redirect']);
// Verify HPOS order state
$updatedOrder = wc_get_order($order->get_id());
$this->assertTrue($updatedOrder->is_paid());
$this->assertSame('ch_test_998877', $updatedOrder->get_transaction_id());
}
public function test_card_decline_returns_failure_and_error_notice(): void {
$order = WC_Helper_Order::create_order();
$order->set_total('50.00');
$order->save();
$_POST['wpstack_payment_token'] = 'tok_declined_card';
add_filter('pre_http_request', static function ($preempt, $args, $url) {
return [
'response' => ['code' => 402, 'message' => 'Payment Required'],
'body' => json_encode([
'status' => 'failed',
'error_message' => 'Insufficient funds in customer account.',
]),
];
}, 10, 3);
$result = $this->gateway->process_payment($order->get_id());
$this->assertSame('failure', $result['result']);
$updatedOrder = wc_get_order($order->get_id());
$this->assertFalse($updatedOrder->is_paid());
}
}Production Troubleshooting and Diagnostics
When operating custom WooCommerce payment gateways across high-concurrency stores, consult this diagnostic matrix:
| Issue Symptom | Root Cause | Diagnostic & Remediation |
|---|---|---|
Customers double-charged on checkout submission | Missing `Idempotency-Key` header or frontend checkout button not disabled upon click. | Pass deterministic `Idempotency-Key: order_{id}_{hash}` and disable submission button via JavaScript. |
Webhook returns 401 Invalid Signature | WordPress security plugin or reverse proxy stripping headers or modifying raw JSON body. | Ensure `$request->get_body()` reads unparsed raw stream and verify webhook secret configuration. |
Float precision errors on multi-currency orders | Using `(int) ($amount * 100)` without `round()`, resulting in `$19.99` becoming `1998` cents. | Always use `CurrencyMinorUnitNormalizer::toMinorUnits()` with proper rounding and currency mapping. |
3DS2 authentication loop on mobile devices | SameSite cookie restrictions blocking session cookies inside 3DS2 iframes. | Configure `SameSite=None; Secure` headers on session and cart cookies across HTTPS domains. |
Gateway missing in React Checkout Block | Missing `@woocommerce/blocks-registry` registration or HPOS compatibility flag. | Register block frontend script and add `FeaturesUtil::declare_compatibility(‘cart_checkout_blocks’)`. |
Refunds failing from WooCommerce admin | Missing `supports => [‘refunds’]` array key or missing transaction ID on order object. | Verify `supports` array in constructor and ensure `_wpstack_charge_id` meta is saved on capture. |
Frequently Asked Questions
What is the difference between synchronous payment capture and webhook reconciliation?
Synchronous capture processes payment and transitions the order during the user’s active checkout HTTP request. Webhook reconciliation receives server-to-server notifications from the payment processor to confirm payment asynchronously, guaranteeing orders complete even if customer internet connections disconnect.
How do custom payment gateways achieve PCI-DSS SAQ-A compliance?
By using client-side JavaScript iframe tokenization (such as Stripe Elements or hosted fields), credit card numbers are transmitted directly from the customer’s browser to the payment processor. The WordPress server only receives an encrypted token, qualifying for SAQ-A.
Why is an Idempotency Key mandatory on payment API calls?
If a network timeout occurs while the server is waiting for an API response, the server might retry the request. An idempotency key ensures the payment processor recognizes duplicate attempts and processes the charge exactly once.
How do I make my custom payment gateway compatible with HPOS?
Declare compatibility during `before_woocommerce_init` using `AutomatticWooCommerceUtilitiesFeaturesUtil::declare_compatibility(‘custom_order_tables’, __FILE__, true)` and use standard `$order->get_*()` and `$order->set_*()` CRUD methods.
Why should order amounts be converted to integer cents?
Floating-point arithmetic in PHP (and all programming languages) can produce binary rounding errors (e.g. `0.1 + 0.2 !== 0.3`). Converting monetary totals to integer minor units (such as `$49.99` to `4999`) guarantees 100% financial calculation precision.
How does 3D Secure 2 (3DS2) Strong Customer Authentication work in WooCommerce?
When the issuing bank requires cardholder verification, the payment API returns a 3DS2 challenge URL. The gateway redirects the customer or loads an authentication modal. Once authenticated, the customer is routed back to complete the transaction.
How do I support WooCommerce React Cart & Checkout Blocks?
Register a client-side JavaScript payment method using `registerPaymentMethod()` from `@woocommerce/blocks-registry` and subscribe to checkout lifecycle hooks using `onPaymentSetup`.
How do I test payment gateways with PHPUnit without real credit cards?
Hook into the `pre_http_request` WordPress filter to mock upstream payment processor responses (e.g. simulating 200 OK approvals, 402 declines, and timeouts), verifying that order statuses transition correctly.
Aditya Bhimrajka is the Chief Search Systems Architect at MoxSEO, leading research in enterprise technical SEO, knowledge graphs, large-scale indexing physics, and generative engine optimization.





