WooCommerce Payment Gateway Integration: Stripe, PayPal, Razorpay
Custom gateways, 3DS handling, saved cards, refunds and idempotent webhooks for Stripe, PayPal, Razorpay and a regional gateway. $8M+ processed across the portfolio.
- Client
- WooCommerce stores and a subscription business selling in multiple currencies
- My role
- WooCommerce developer: payment gateway plugins and webhook layer
- WooCommerce
- WC_Payment_Gateway
- WC_Payment_Gateway_CC
- Stripe
- PayPal Orders v2
- Razorpay
- Paystack
- Action Scheduler
- PHP
- processed across Stripe, PayPal, Razorpay and a regional gateway
- $8M+
- authorization success rate
- 92% → 96%
- 3DS completion
- 78% → 91%
- refund turnaround
- 2 hrs → 5 min
- duplicate renewal or order creations
- ≈ 200/yr → 0
processed across Stripe, PayPal, Razorpay and a regional gateway
authorization success rate
3DS completion
refund turnaround
duplicate renewal or order creations
The challenge
Clients were on a mix of Stripe and PayPal plus a regional gateway (Razorpay for India, Paystack for Africa). The recurring failures were saved cards disappearing after a plugin update, refunds needing a developer, 3DS/SCA dropping customers on the return URL, and webhooks creating duplicate orders or duplicate subscription renewals.
One client also sold in 14 currencies but exposed gateways that could not charge in the customer's currency, so checkouts failed at the last step.
What I built
- 01
Custom gateway plugin for a regional provider
Extends WC_Payment_Gateway with a hosted payment page, redirect and return handler, process_refund and webhooks. A second, direct-post integration uses WC_Payment_Gateway_CC card fields.
- 02
3DS/SCA handling
process_payment returns ['result' => 'success', 'redirect' => $bank_url] when authentication is required and the order goes on-hold. The order completes only after a verified return or webhook. No payment_complete() before authentication.
- 03
Tokenization and saved cards
supports => ['tokenization'], add_payment_token(), token ownership checks, and a "save card" checkbox wired to woocommerce_payment_token_class.
- 04
Refunds
process_refund(), including partial refunds mapped to the gateway's refund API, plus an admin bulk-refund action for a subscription client that was doing 100+ refunds a month by hand.
- 05
Multi-currency gateway filtering
Gateways that do not support the active currency are hidden, and the correct currency is forced at the gateway API call instead of trusting the session.
- 06
Idempotent webhook layer
Events are persisted to a custom table with a unique index, processed through Action Scheduler and retried with backoff.
- 07
Razorpay and PayPal
Razorpay with amounts in paise, plus checkout signature and webhook signature verification. PayPal Orders v2 with capture vs authorize and the webhook verification endpoint.
Gotchas and how I fixed them
process_refund() must return true or WP_Error
Returning false is treated as a failure, and returning null produces a confusing "refund failed" notice with no log line.
Return URL does not mean payment confirmed
Users can close the tab after paying, and can also hit the return URL manually. The webhook is the source of truth; treat the return handler as a UX redirect only.
Razorpay signature order
It is razorpay_order_id + "|" + razorpay_payment_id, not the reverse, using HMAC-SHA256 with the key secret. Webhooks sign the raw body, so read php://input once and verify before json_decode.
PayPal webhooks are not ordered
PAYMENT.CAPTURE.COMPLETED can arrive before your local order is committed, and PENDING → COMPLETED transitions are normal. Do not fulfil on PENDING, and do not assume COMPLETED arrives first.
get_woocommerce_currency() in multi-currency stores
During admin or AJAX it can return the base currency while the session holds the shopper's currency. Read the session currency and pass it explicitly to the gateway API; do not let the gateway infer it.
PCI scope is a business decision
Hosted fields or iframes keep you in SAQ A; direct-post card fields put you in SAQ A-EP. Document the choice before picking the integration style.
Always check saved-card token ownership
Check $token->get_user_id() === get_current_user_id(). A token ID is a number in a POST field, so never trust it.
Code from the build
class WC_Gateway_MyPay extends WC_Payment_Gateway {
public function __construct() {
$this->id = 'mypay';
$this->has_fields = true;
$this->supports = [ 'products', 'refunds', 'tokenization', 'add_payment_method' ];
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option( 'title' );
add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, [ $this, 'process_admin_options' ] );
add_action( 'woocommerce_api_wc_gateway_mypay', [ $this, 'handle_return' ] );
}
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
$response = wp_remote_post( $this->api_base . '/payments', [
'timeout' => 20,
'headers' => [ 'Authorization' => 'Bearer ' . $this->secret_key ],
'body' => [
'amount' => (int) round( $order->get_total() * 100 ),
'currency' => $order->get_currency(),
'order_id' => $order->get_id(),
],
] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
wc_add_notice( __( 'Payment could not be initiated.', 'my-textdomain' ), 'error' );
return [ 'result' => 'failure' ];
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
// 3DS / SCA: do NOT complete the order yet.
if ( ! empty( $body['next_action']['redirect_url'] ) ) {
$order->update_status( 'on-hold', __( 'Awaiting 3DS authentication.', 'my-textdomain' ) );
return [
'result' => 'success',
'redirect' => esc_url_raw( $body['next_action']['redirect_url'] ),
];
}
$order->payment_complete( sanitize_text_field( $body['id'] ) );
WC()->cart->empty_cart();
return [ 'result' => 'success', 'redirect' => $this->get_return_url( $order ) ];
}
public function process_refund( $order_id, $amount = null, $reason = '' ) {
$order = wc_get_order( $order_id );
$txn = $order->get_transaction_id();
if ( ! $txn ) {
return new WP_Error( 'mypay_no_txn', __( 'No transaction ID on this order.', 'my-textdomain' ) );
}
$response = wp_remote_post( $this->api_base . '/refunds', [
'timeout' => 20,
'headers' => [ 'Authorization' => 'Bearer ' . $this->secret_key ],
'body' => [
'payment_id' => $txn,
'amount' => (int) round( $amount * 100 ),
],
] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'mypay_refund_failed', __( 'Gateway rejected the refund.', 'my-textdomain' ) );
}
$order->add_order_note( sprintf(
__( 'Refunded %1$s via MyPay. Reason: %2$s', 'my-textdomain' ),
wc_price( $amount ),
$reason ?: __( 'none', 'my-textdomain' )
) );
return true; // false/null = WooCommerce treats it as a failure.
}
}The 3DS branch is the important part: completing the order before authentication is how you get "paid" orders that later reverse.
// Checkout return: HMAC over order_id|payment_id — in that order.
$expected = hash_hmac(
'sha256',
$razorpay_order_id . '|' . $razorpay_payment_id,
$key_secret
);
if ( ! hash_equals( $expected, $razorpay_signature ) ) {
wp_die( 'Invalid signature', 400 );
}
// Webhook: verify the RAW body, before json_decode.
$raw = file_get_contents( 'php://input' );
$signature = $_SERVER['HTTP_X_RAZORPAY_SIGNATURE'] ?? '';
if ( ! hash_equals( hash_hmac( 'sha256', $raw, $webhook_secret ), $signature ) ) {
status_header( 400 );
exit;
}
$event = json_decode( $raw, true );php://input can only be read once, so read it before any $_POST access, and use hash_equals to avoid timing leaks.
add_filter( 'woocommerce_available_payment_gateways', function ( $gateways ) {
$currency = WC()->session ? WC()->session->get( 'currency' ) : get_woocommerce_currency();
$unsupported = [
'razorpay' => [ 'INR' ],
'paystack' => [ 'NGN', 'GHS', 'ZAR', 'KES' ],
];
foreach ( $unsupported as $gateway_id => $currencies ) {
if ( isset( $gateways[ $gateway_id ] ) && ! in_array( $currency, $currencies, true ) ) {
unset( $gateways[ $gateway_id ] );
}
}
return $gateways;
} );Without this the shopper picks a gateway, reaches the last step and the charge fails, which is the worst place to lose a conversion.
Results
- $8M+ processed across Stripe, PayPal, Razorpay and one regional gateway over the portfolio.
- Authorization success rate went from about 92% to about 96% after fixing currency/session mismatch and stale saved-card tokens.
- 3DS completion went from about 78% to about 91% after replacing the "return URL then poll" flow with a verified webhook and a proper on-hold state.
- Refund turnaround went from about 2 hours to about 5 minutes, recovering about 40 hrs/month of admin time on the subscription client.
- Duplicate renewal or order creation from out-of-order webhooks went from about 200 a year to 0.
- Checkout conversion rose 12% on the multi-currency store after hiding unsupported gateways per currency.
Questions people ask
Can you build a custom WooCommerce payment gateway for a regional provider?
Yes. I have built a gateway plugin extending WC_Payment_Gateway with a hosted payment page, redirect and return handler, refunds and webhooks, plus a direct-post version using WC_Payment_Gateway_CC card fields. I have also integrated Razorpay and PayPal Orders v2.
Why do 3D Secure payments fail or drop customers on WooCommerce?
Often the order is completed or polled from the return URL. I put the order on-hold and send the customer to the bank, then complete it only after a verified return or webhook. That moved 3DS completion from about 78% to about 91%.
How do I stop WooCommerce webhooks creating duplicate orders or renewals?
Persist each event to a custom table with a unique index, process it through Action Scheduler, and retry with backoff. Duplicate creations from out-of-order webhooks went from about 200 a year to 0 on one subscription client.
Why does my WooCommerce refund show "refund failed" with no log?
process_refund() must return true or a WP_Error. Returning false counts as failure, and returning null produces a vague notice with no log line.
How do I stop customers picking a gateway that cannot charge their currency?
Filter woocommerce_available_payment_gateways by the active session currency and pass the currency explicitly to the gateway API. On a store selling in 14 currencies, this lifted checkout conversion 12%.