Payments

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+

processed across Stripe, PayPal, Razorpay and a regional gateway

authorization success rate
92% → 96%

authorization success rate

3DS completion
78% → 91%

3DS completion

refund turnaround
2 hrs → 5 min

refund turnaround

duplicate renewal or order creations
≈ 200/yr → 0

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

  1. 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.

  2. 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.

  3. 03

    Tokenization and saved cards

    supports => ['tokenization'], add_payment_token(), token ownership checks, and a "save card" checkbox wired to woocommerce_payment_token_class.

  4. 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.

  5. 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.

  6. 06

    Idempotent webhook layer

    Events are persisted to a custom table with a unique index, processed through Action Scheduler and retried with backoff.

  7. 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

Custom gateway skeleton with 3DS branch and refundphp
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.

Razorpay signature verification (checkout and webhook)php
// 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.

Hide gateways that cannot charge the active currencyphp
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%.