WordPress · WooCommerce · PHP 8.1+

WooCommerce UPI checkout with signed webhooks

Register a redirect gateway, create the payment from trusted order data, store the provider reference, and complete the WooCommerce order only after HMAC verification.

01

Configure secrets

02

Create order

03

Redirect customer

04

Verify webhook

Prerequisites

Decide the supported checkout surface

The implementation below targets WooCommerce classic checkout. `WC_Payment_Gateway` alone does not register a method in Checkout Blocks. If the store uses blocks, implement WooCommerce’s separate Blocks payment-method integration or intentionally use the classic checkout shortcode.

Use HTTPS, current WordPress and WooCommerce releases, PHP 8.1 or newer, a supported connected merchant provider account, a test API key, and a test webhook secret. Back up and stage the store before enabling a new payment method.

Step 1

Store credentials outside theme code

Copy the exact environment base URL and test credentials from the dashboard. Never commit real values. A managed host’s secret store is preferable; `wp-config.php` is the minimum server-side boundary shown here.

// wp-config.php — keep above "That's all, stop editing"
define('VG_BASE_URL', 'https://base-url-from-your-dashboard.example');
define('VG_API_KEY', 'vg_test_replace_with_dashboard_key');
define('VG_WEBHOOK_SECRET', 'whsec_test_replace_with_dashboard_secret');

Steps 2–3

Register the gateway and create a payment order

Create `wp-content/plugins/vyapargateway-upi/vyapargateway-upi.php` with this complete classic-checkout gateway. The stable `client_txn_id` makes a retry for the same WooCommerce order idempotent.

<?php
/**
 * Plugin Name: VyaparGateway UPI for WooCommerce
 * Description: Redirect-based UPI checkout with signed payment callbacks.
 * Version: 1.0.0
 * Requires Plugins: woocommerce
 */

defined('ABSPATH') || exit;

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

add_filter('woocommerce_payment_gateways', static function (array $gateways): array {
    $gateways[] = 'WC_Gateway_VyaparGateway_UPI';
    return $gateways;
});

add_action('plugins_loaded', static function (): void {
    if (!class_exists('WC_Payment_Gateway')) {
        return;
    }

    final class WC_Gateway_VyaparGateway_UPI extends WC_Payment_Gateway
    {
        public function __construct()
        {
            $this->id = 'vyapargateway_upi';
            $this->method_title = 'VyaparGateway UPI';
            $this->method_description =
                'Creates a hosted UPI payment order and verifies payment by webhook.';
            $this->has_fields = false;
            $this->supports = ['products'];

            $this->init_form_fields();
            $this->init_settings();

            $this->title = (string) $this->get_option('title');
            $this->description = (string) $this->get_option('description');
            $this->enabled = (string) $this->get_option('enabled');

            add_action(
                'woocommerce_update_options_payment_gateways_' . $this->id,
                [$this, 'process_admin_options']
            );
        }

        public function init_form_fields(): void
        {
            $this->form_fields = [
                'enabled' => [
                    'title' => 'Enable/Disable',
                    'type' => 'checkbox',
                    'label' => 'Enable VyaparGateway UPI',
                    'default' => 'no',
                ],
                'title' => [
                    'title' => 'Checkout title',
                    'type' => 'text',
                    'default' => 'UPI',
                    'desc_tip' => true,
                ],
                'description' => [
                    'title' => 'Checkout description',
                    'type' => 'textarea',
                    'default' => 'Pay securely with a supported UPI app.',
                ],
            ];
        }

        public function process_payment($order_id): array
        {
            if (
                !defined('VG_BASE_URL') ||
                !defined('VG_API_KEY') ||
                VG_BASE_URL === '' ||
                VG_API_KEY === ''
            ) {
                wc_add_notice(
                    'UPI checkout is not configured. Please choose another method.',
                    'error'
                );
                return ['result' => 'failure'];
            }

            $order = wc_get_order($order_id);
            if (!$order instanceof WC_Order) {
                wc_add_notice('Unable to load the order.', 'error');
                return ['result' => 'failure'];
            }

            $client_txn_id = 'WC-' . $order->get_id();
            $body = [
                'client_txn_id' => $client_txn_id,
                'amount' => wc_format_decimal($order->get_total(), 2),
                'p_info' => 'WooCommerce order ' . $order->get_order_number(),
                'customer_name' => trim(
                    $order->get_billing_first_name() . ' ' .
                    $order->get_billing_last_name()
                ),
                'customer_email' => $order->get_billing_email(),
                'customer_mobile' => $order->get_billing_phone(),
                'redirect_url' => $this->get_return_url($order),
                'callback_url' => rest_url('vyapargateway/v1/webhook'),
            ];

            $response = wp_remote_post(
                untrailingslashit(VG_BASE_URL) . '/api/v1/create_order',
                [
                    'timeout' => 15,
                    'redirection' => 0,
                    'headers' => [
                        'Content-Type' => 'application/json',
                        'X-API-Key' => VG_API_KEY,
                    ],
                    'body' => wp_json_encode($body),
                ]
            );

            if (is_wp_error($response)) {
                wc_add_notice(
                    'UPI checkout is temporarily unavailable. Please retry.',
                    'error'
                );
                return ['result' => 'failure'];
            }

            $status_code = wp_remote_retrieve_response_code($response);
            $payload = json_decode(
                wp_remote_retrieve_body($response),
                true,
                512,
                JSON_THROW_ON_ERROR
            );

            if (
                $status_code < 200 ||
                $status_code >= 300 ||
                empty($payload['status']) ||
                empty($payload['data']['order_id']) ||
                empty($payload['data']['payment_url']) ||
                ($payload['data']['client_txn_id'] ?? '') !== $client_txn_id
            ) {
                wc_add_notice('The payment order was rejected.', 'error');
                return ['result' => 'failure'];
            }

            $order->update_meta_data(
                '_vg_order_id',
                sanitize_text_field($payload['data']['order_id'])
            );
            $order->update_meta_data('_vg_client_txn_id', $client_txn_id);
            $order->update_status('pending', 'Awaiting verified UPI payment.');
            $order->save();

            return [
                'result' => 'success',
                'redirect' => esc_url_raw($payload['data']['payment_url']),
            ];
        }
    }
});

Catch `JsonException` around malformed provider responses in a hardened plugin and log a redacted correlation ID through `wc_get_logger()`. Do not log the API key or full customer payload.

Step 4

Verify the raw-body signature and complete once

Add this code in the same plugin file after the gateway registration. The route is publicly reachable because the sender is a payment service, but the timestamp and HMAC authenticate each request.

add_action('rest_api_init', static function (): void {
    register_rest_route('vyapargateway/v1', '/webhook', [
        'methods' => 'POST',
        'callback' => 'vg_handle_payment_webhook',
        'permission_callback' => '__return_true',
    ]);
});

function vg_handle_payment_webhook(WP_REST_Request $request): WP_REST_Response
{
    if (!defined('VG_WEBHOOK_SECRET') || VG_WEBHOOK_SECRET === '') {
        return new WP_REST_Response(['error' => 'Not configured'], 503);
    }

    $raw_body = $request->get_body();
    $timestamp = (string) $request->get_header(
        'x-vyapargateway-timestamp'
    );
    $received = strtolower((string) $request->get_header(
        'x-vyapargateway-signature'
    ));

    if (
        !ctype_digit($timestamp) ||
        abs(time() - (int) $timestamp) > 300 ||
        !preg_match('/^[a-f0-9]{64}$/', $received)
    ) {
        return new WP_REST_Response(['error' => 'Invalid signature input'], 401);
    }

    $expected = hash_hmac(
        'sha256',
        $timestamp . '.' . $raw_body,
        VG_WEBHOOK_SECRET
    );

    if (!hash_equals($expected, $received)) {
        return new WP_REST_Response(['error' => 'Invalid signature'], 401);
    }

    try {
        $event = json_decode($raw_body, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException $exception) {
        return new WP_REST_Response(['error' => 'Invalid JSON'], 400);
    }

    $client_txn_id = (string) ($event['client_txn_id'] ?? '');
    if (!preg_match('/^WC-(\d+)$/', $client_txn_id, $matches)) {
        return new WP_REST_Response(['error' => 'Unknown order reference'], 400);
    }

    $order = wc_get_order((int) $matches[1]);
    if (!$order instanceof WC_Order) {
        return new WP_REST_Response(['error' => 'Order not found'], 404);
    }

    $provider_order_id = (string) ($event['order_id'] ?? '');
    $stored_provider_id = (string) $order->get_meta('_vg_order_id', true);
    $expected_amount = wc_format_decimal($order->get_total(), 2);
    $received_amount = wc_format_decimal(
        (string) ($event['amount'] ?? ''),
        2
    );

    if (
        $provider_order_id === '' ||
        !hash_equals($stored_provider_id, $provider_order_id) ||
        ($event['currency'] ?? '') !== 'INR' ||
        $expected_amount !== $received_amount
    ) {
        return new WP_REST_Response(['error' => 'Order mismatch'], 409);
    }

    if (($event['status'] ?? '') !== 'success') {
        return new WP_REST_Response(['received' => true], 200);
    }

    if (!$order->is_paid()) {
        $transaction_id = sanitize_text_field(
            (string) ($event['upi_txn_id'] ?? $provider_order_id)
        );
        $order->payment_complete($transaction_id);
        $order->add_order_note(
            'UPI payment verified by signed VyaparGateway webhook.'
        );
        $order->save();
    }

    return new WP_REST_Response(null, 204);
}

Test matrix

Prove the unhappy paths in staging

01

Create retry

Repeat the same order after an HTTP timeout and confirm one provider order is reused.

02

Signature rejection

Change one payload byte, timestamp, and signature; each request must return 401.

03

Amount mismatch

Send a validly signed event with the wrong amount and confirm the order stays pending.

04

Duplicate success

Deliver the same success twice and confirm stock and fulfilment run once.

05

Redirect race

Load the thank-you URL before the webhook and display a truthful pending state.

06

Late success

Authorise near expiry and reconcile before marking the order permanently failed.

07

Plugin disabled

Confirm existing paid orders remain queryable and support can reconcile references.

08

Blocks checkout

Confirm the gateway is not silently missing when the store uses Checkout Blocks.

Questions

WooCommerce integration FAQ

Does the VyaparGateway API key go into WordPress JavaScript?
No. Store it in server configuration or a protected secret-management mechanism. The WooCommerce server creates the order and returns only the customer-safe hosted payment URL.
Why is the WordPress webhook route public?
The payment service, not a logged-in WordPress user, sends the request. The route therefore cannot require a customer session; it authenticates the sender with a timestamped HMAC signature over the exact raw body.
Does WC_Payment_Gateway automatically support Checkout Blocks?
No. A classic WC_Payment_Gateway implementation supports classic checkout. WooCommerce Checkout Blocks require a separate payment-method integration registered through the Blocks APIs, or the store must intentionally use classic checkout.
Should the return URL mark a WooCommerce order paid?
No. The return URL is controlled through the customer's browser. Mark the order paid only after a valid webhook or authenticated status reconciliation matches the local order, provider order, amount, and currency.
How are duplicate payment webhooks handled?
Check whether the order is already paid before calling payment_complete, and store provider identifiers in order metadata. For higher assurance, add a dedicated event table with a unique event or payload key.

Keep the first launch small and observable.

Enable the method for a controlled cohort before all store traffic.

View all integrations