WooCommerce eSewa, Khalti & Fonepay integration: a developer’s guide

If you run a WooCommerce store in Nepal, card payments alone won’t cut it. Most of your customers will want to pay with eSewa, Khalti, or a Fonepay QR scan from their banking app. This guide covers how each gateway works, when a plugin is enough, what a custom gateway looks like, and the security checks I see missing from a lot of integrations.

The three gateways at a glance

All three work in Nepali rupees only, so your WooCommerce store currency should be set to NPR. Each gateway gives you test credentials and a sandbox environment. Build and test against those before switching to live keys.

Plugin or custom gateway?

There are existing plugins on WordPress.org and from the gateways themselves for eSewa and Khalti. For a simple store, a well-maintained plugin is often the right call. Before installing one, check:

I build a custom gateway when the store needs something a plugin doesn’t do well: multiple gateways with shared order handling, custom refund workflows, ERP or inventory sync, or a gateway that has no reliable plugin at all.

How a custom WooCommerce gateway is structured

Every gateway is a class that extends WC_Payment_Gateway, registered through the woocommerce_payment_gateways filter. The important method is process_payment(), which returns the URL to send the customer to:

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

class My_Khalti_Gateway extends WC_Payment_Gateway {
    public function process_payment( $order_id ) {
        $order = wc_get_order( $order_id );
        // Start the payment with the gateway, then redirect.
        return [
            'result'   => 'success',
            'redirect' => $payment_url,
        ];
    }
}

For the return trip, register a WooCommerce API endpoint with add_action( 'woocommerce_api_khalti', ... ) and use WC()->api_request_url( 'khalti' ) as the gateway’s return URL. That handler verifies the payment and completes the order.

eSewa: signed form POST

eSewa’s ePay v2 expects an HTML form POSTed from the customer’s browser to eSewa’s form URL. The form includes the amounts, a unique transaction_uuid, your product_code (merchant code), success and failure URLs, and an HMAC-SHA256 signature of the fields listed in signed_field_names:

$message   = "total_amount={$total},transaction_uuid={$uuid},product_code={$product_code}";
$signature = base64_encode( hash_hmac( 'sha256', $message, $secret_key, true ) );

Because it’s a browser POST rather than a server call, the usual WooCommerce pattern is to redirect from process_payment() to the order’s pay page and output an auto-submitting form there via the woocommerce_receipt_{gateway_id} hook.

After payment, eSewa redirects to your success URL with a Base64-encoded JSON response that includes its own signature. Verify that signature, then confirm the transaction with eSewa’s transaction status API before you mark the order paid.

Khalti: server-side initiate and lookup

Khalti’s ePayment flow starts on your server. You POST the order details to the initiate endpoint with your secret key, and Khalti returns a pidx (payment ID) and a payment_url. Amounts are in paisa, so multiply rupees by 100:

$response = wp_remote_post( 'https://dev.khalti.com/api/v2/epayment/initiate/', [
    'headers' => [
        'Authorization' => 'Key ' . $secret_key,
        'Content-Type'  => 'application/json',
    ],
    'body'    => wp_json_encode( [
        'return_url'          => WC()->api_request_url( 'khalti' ),
        'website_url'         => home_url( '/' ),
        'amount'              => (int) round( $order->get_total() * 100 ),
        'purchase_order_id'   => (string) $order->get_id(),
        'purchase_order_name' => 'Order #' . $order->get_order_number(),
    ] ),
] );

$body = json_decode( wp_remote_retrieve_body( $response ), true );
$order->update_meta_data( '_khalti_pidx', $body['pidx'] );
$order->save();
// Redirect the customer to $body['payment_url']

When the customer returns, call Khalti’s lookup endpoint with the stored pidx. Only a lookup status of Completed, with an amount that matches the order, means the order is paid. The URL above is the sandbox; switch to the production base URL and live key when you go live.

Fonepay: bank-network payments and QR

Fonepay works differently. You don’t sign up online; you get a merchant account through one of Fonepay’s partner banks, and the integration documentation and credentials are shared with you once you’re onboarded. The web flow follows the same shape as the others: build a request with a unique payment reference and an HMAC signature, redirect the customer, and verify the response server-side using Fonepay’s verification API. Many shops also use Fonepay’s dynamic QR, where the customer scans a code generated for the exact order amount.

Security checks most integrations miss

Testing before you go live

Run complete test orders in each sandbox: a successful payment, a cancellation, a failed payment, and a refresh of the return page. Confirm the order status, stock levels, and emails are correct each time. Then switch to live credentials and place one small real order per gateway before you announce anything.

Gateway APIs change, so always check the current official documentation from eSewa, Khalti, and Fonepay before you build. Endpoints and field names here reflect the APIs at the time of writing.

Need eSewa, Khalti, or Fonepay on your WooCommerce store?

I build and fix WooCommerce stores for businesses in Nepal, including payment gateway integrations. See my WordPress & WooCommerce development services.

Chat on WhatsApp