developer

How to Accept UPI in WooCommerce Without Razorpay

Build a WooCommerce UPI checkout without Razorpay using server-side order creation, dynamic QR or intent links, signed webhooks, and safe order states.

VE VyaparGateway Engineering Commerce Integrations 5 min read

Fact-checked and updated

How to Accept UPI in WooCommerce Without Razorpay guide
WooCommerce UPI WordPress payment gateway Razorpay alternative UPI webhook

You do not need Razorpay specifically to offer UPI in WooCommerce. WooCommerce exposes a payment-gateway extension API, and a compatible UPI provider can sit behind it. The hard part is not showing a QR code. The hard part is reliably mapping an external bank payment to one WooCommerce order and changing stock or fulfilment exactly once.

This tutorial uses VyaparGateway’s server-to-server order pattern as the example. The same state-machine principles apply to another provider, but request fields, response fields, signatures, and commercial terms will differ.

WooCommerce UPI architecture

Keep credentials and payment decisions on the WordPress server:

WooCommerce checkout
       │ customer chooses UPI

WordPress gateway plugin
       │ POST /api/v1/create_order (X-API-Key)

Payment API ──► dynamic QR + intent links

       ├── customer pays in a UPI app

       └── signed webhook ──► WordPress REST endpoint


                       $order->payment_complete()

Use these WooCommerce states deliberately:

StateMeaning in this integration
pendingOrder exists but no verified payment has arrived
on-holdOptional state while the customer is actively completing UPI
processingPayment verified; physical fulfilment can begin
completedFulfilment finished, or WooCommerce auto-completed a virtual order
failedProvider rejected or the payable window expired
cancelledCustomer or store intentionally cancelled

Do not use processing as a generic “customer scanned the QR” state. In WooCommerce, it carries paid-order semantics.

Prerequisites

  • HTTPS on the public WordPress site;
  • WooCommerce with HPOS compatibility enabled in your extension;
  • a merchant account and connected payment provider;
  • a server-side API key;
  • a distinct webhook secret;
  • a public callback URL that bypasses page caches;
  • a staging store or sandbox account.

Create the payment gateway class

Build a small WordPress plugin rather than placing payment code in functions.php. A plugin has a clear lifecycle, settings surface, and upgrade path.

Register the gateway:

<?php
/**
 * Plugin Name: Store UPI Gateway
 * Description: Server-verified UPI checkout for WooCommerce.
 * Version: 1.0.0
 * Requires Plugins: woocommerce
 */

defined('ABSPATH') || exit;

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

    require_once __DIR__ . '/includes/class-wc-gateway-store-upi.php';
});

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

Inside the gateway class, declare WooCommerce support and private settings:

<?php

defined('ABSPATH') || exit;

final class WC_Gateway_Store_UPI extends WC_Payment_Gateway {
    public function __construct() {
        $this->id                 = 'store_upi';
        $this->method_title       = 'UPI (server verified)';
        $this->method_description = 'Dynamic UPI QR and intent checkout.';
        $this->has_fields         = false;
        $this->supports           = ['products'];

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

        $this->title       = $this->get_option('title', 'UPI');
        $this->description = $this->get_option(
            'description',
            'Pay using any supported UPI app.'
        );

        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',
                'type'    => 'checkbox',
                'label'   => 'Enable UPI checkout',
                'default' => 'no',
            ],
            'title' => [
                'title'   => 'Checkout title',
                'type'    => 'text',
                'default' => 'UPI',
            ],
            'api_base' => [
                'title'   => 'API base URL',
                'type'    => 'url',
                'default' => 'https://vyapargateway.com/api/v1',
            ],
            'api_key' => [
                'title' => 'API key',
                'type'  => 'password',
            ],
            'webhook_secret' => [
                'title' => 'Webhook secret',
                'type'  => 'password',
            ],
        ];
    }
}

For larger stores, inject secrets from the server environment and show only a “configured” status in WordPress admin. Database-backed WordPress options are convenient but should not become a reason to give broad database exports to support staff.

Create the remote payment order

process_payment() runs on the server. Use the WooCommerce order ID plus its immutable order key-derived value as your idempotency reference. Never trust an amount posted directly from the browser; read the total from the WooCommerce order.

public function process_payment($order_id): array {
    $order = wc_get_order($order_id);

    if (!$order) {
        throw new Exception('Order not found.');
    }

    $client_txn_id = sprintf(
        'wc_%d_%s',
        $order->get_id(),
        substr(hash('sha256', $order->get_order_key()), 0, 16)
    );

    $response = wp_remote_post(
        trailingslashit($this->get_option('api_base')) . 'create_order',
        [
            'timeout' => 20,
            'headers' => [
                'Content-Type' => 'application/json',
                'X-API-Key'    => $this->get_option('api_key'),
            ],
            'body' => wp_json_encode([
                'client_txn_id'  => $client_txn_id,
                'amount'         => (float) $order->get_total(),
                '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('store-upi/v1/webhook'),
            ]),
        ]
    );

    if (is_wp_error($response)) {
        throw new Exception('UPI service is temporarily unavailable.');
    }

    $status = wp_remote_retrieve_response_code($response);
    $body   = json_decode(wp_remote_retrieve_body($response), true);

    if ($status !== 200 || empty($body['status']) || empty($body['data'])) {
        throw new Exception('Unable to create the UPI payment.');
    }

    $order->update_meta_data('_store_upi_client_txn_id', $client_txn_id);
    $order->update_meta_data('_store_upi_order_id', $body['data']['order_id'] ?? '');
    $order->update_meta_data('_store_upi_payload', [
        'qr'      => $body['data']['qr_code'] ?? null,
        'upi_uri' => $body['data']['upi_string'] ?? null,
        'expires' => $body['data']['expires_at'] ?? null,
    ]);
    $order->save();

    return [
        'result'   => 'success',
        'redirect' => $order->get_checkout_payment_url(true),
    ];
}

Confirm the exact response keys in the authenticated API reference you are using. Public examples can lag a deployed API, and your plugin should fail closed if a required identifier is missing.

Verify payment webhooks

Register a dedicated REST route:

add_action('rest_api_init', function (): void {
    register_rest_route('store-upi/v1', '/webhook', [
        'methods'             => 'POST',
        'callback'            => 'store_upi_handle_webhook',
        'permission_callback' => '__return_true',
    ]);
});

permission_callback is public because the provider—not a logged-in WordPress user—calls it. Authentication happens by verifying the signature. Do not replace signature verification with an IP allowlist; provider egress ranges can change and proxies can complicate source IPs.

function store_upi_handle_webhook(WP_REST_Request $request): WP_REST_Response {
    $raw       = $request->get_body();
    $received  = (string) $request->get_header('x-vyapargateway-signature');
    $secret    = (string) get_option('woocommerce_store_upi_settings')['webhook_secret'];
    $expected  = hash_hmac('sha256', $raw, $secret);

    if ($received === '' || !hash_equals($expected, $received)) {
        return new WP_REST_Response(['ok' => false], 401);
    }

    $event = json_decode($raw, true);
    if (!is_array($event)) {
        return new WP_REST_Response(['ok' => false], 400);
    }

    $client_txn_id = sanitize_text_field($event['client_txn_id'] ?? '');
    $transaction_id = sanitize_text_field($event['transaction_id'] ?? '');

    $orders = wc_get_orders([
        'limit'      => 1,
        'meta_key'   => '_store_upi_client_txn_id',
        'meta_value' => $client_txn_id,
    ]);
    $order = $orders[0] ?? null;

    if (!$order) {
        return new WP_REST_Response(['ok' => false], 404);
    }

    if ($order->get_meta('_store_upi_transaction_id') === $transaction_id) {
        return new WP_REST_Response(['ok' => true, 'duplicate' => true], 200);
    }

    $expected_amount = (float) $order->get_total();
    $paid_amount     = (float) ($event['amount'] ?? 0);
    $paid            = ($event['status'] ?? '') === 'success';

    if (!$paid || abs($expected_amount - $paid_amount) > 0.001) {
        $order->add_order_note('UPI event rejected: status or amount mismatch.');
        return new WP_REST_Response(['ok' => false], 409);
    }

    $order->update_meta_data('_store_upi_transaction_id', $transaction_id);
    $order->payment_complete($transaction_id);
    $order->add_order_note('UPI payment verified by signed webhook.');
    $order->save();

    return new WP_REST_Response(['ok' => true], 200);
}

In a production plugin, also validate the merchant identity, currency, event timestamp, and event type. Store a bounded audit record without retaining unnecessary personal data.

Render a mobile-friendly checkout

On mobile, the fastest path is usually a UPI intent button. On desktop, show a dynamic QR plus the merchant name, exact amount, and expiry.

Mobile
  Pay with any UPI app
      └── upi://pay?pa=...&pn=...&tr=...&am=...&cu=INR

Desktop
  [dynamic QR]
  Order WC-1842 · ₹1,249.00 · expires in 02:00

Do not auto-open an app without a user gesture. Browsers can block the navigation, and unexpected app switches are poor accessibility. Always provide:

  • a visible “Pay with any UPI app” link;
  • an accessible QR description;
  • a copyable UPI ID only when your reconciliation flow supports it;
  • a “Check payment status” action;
  • a clear expiry message;
  • a way to choose another payment method.

Refresh status only as a customer convenience. The server-side webhook remains the source of truth. Stop periodic checks when the page is hidden, the order resolves, or the payment window expires.

Test before going live

Functional matrix

Test at least these cases:

  1. Successful payment from Google Pay, PhonePe, Paytm, and BHIM where available.
  2. Customer closes the app before authorising.
  3. Payment succeeds but the browser never returns.
  4. Webhook arrives twice.
  5. Webhook arrives before the return page loads.
  6. Amount mismatch.
  7. Invalid signature.
  8. Expired order followed by a late credit.
  9. WordPress maintenance mode or temporary 500 response.
  10. A virtual product and a physical product with different fulfilment states.

WordPress-specific failure points

  • Exclude the webhook route and payment page from full-page caching.
  • Ensure Wordfence or another WAF does not challenge the provider callback.
  • Keep PHP’s request-body access intact before signature verification.
  • Use Action Scheduler for slow fulfilment, not the webhook request thread.
  • Verify compatibility with WooCommerce High-Performance Order Storage.
  • Do not log API keys, webhook secrets, full request headers, or base64 QR images.

Cost check

The absence of Razorpay does not automatically mean the new setup is cheaper. Compare the UPI provider’s subscription, method-level charges, support, reconciliation work, and any instant-settlement cost. For Razorpay’s public 2% domestic platform-fee example, remember that 18% GST applies to the fee, yielding an illustrative 2.36% effective cost. Other providers publish different UPI treatment.

Go-live checklist

  • Live API and webhook secrets configured outside source control.
  • Test credentials removed from production settings.
  • Webhook endpoint reachable over HTTPS without a browser challenge.
  • Signature and amount mismatch tests fail closed.
  • Duplicate events do not repeat stock reduction or email.
  • Order status reconciliation runs on a schedule.
  • Checkout states are translated into clear customer language.
  • Refund operations and support ownership are documented.

WooCommerce gives you enough extension points to build a reliable UPI flow without coupling your store to one gateway brand. Keep the integration small, server-side, signed, idempotent, and explicit about order state.

Direct answers

Frequently asked questions

Can WooCommerce accept UPI without Razorpay?
Yes. WooCommerce can use any compatible payment provider through a maintained gateway plugin or a custom WC_Payment_Gateway extension. The integration should create orders server-side and confirm payment through a signed webhook or authenticated status lookup.
Should WooCommerce mark an order paid after the customer returns from a UPI app?
No. A return URL only proves that the browser came back. Keep the order pending until your server verifies a signed payment event or an authenticated provider status response with the expected amount and reference.
Which WooCommerce status should a successful UPI payment use?
Call WooCommerce payment_complete on verified success. WooCommerce then normally chooses Processing for fulfilment-required products or Completed for virtual and downloadable orders, depending on product and store configuration.
Where should the VyaparGateway API key be stored in WordPress?
Store it in protected server-side plugin settings or environment-backed configuration. Never expose it in checkout HTML, JavaScript, browser logs, or public REST responses.
How do I prevent duplicate fulfilment from webhook retries?
Persist the provider event or transaction ID in order metadata, use a unique check before changing state, and make the handler return success for an already-processed event without repeating stock, email, or fulfilment actions.

Build your payment flow

Explore the API and browser-only merchant tools.

Create UPI checkout orders, verify signed events, or test the free calculators and generators without exposing credentials.