WooCommerce
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
- eSewa: a digital wallet. The customer is sent to eSewa’s payment page via a signed form POST, then returned to your site.
- Khalti: a wallet that also supports mobile banking and cards. Your server calls Khalti’s API to start the payment, then redirects the customer to the payment URL it returns.
- Fonepay: a bank-network payment system best known for its QR codes. You sign up through a partner bank, and the integration docs and credentials come with your merchant account.
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:
- It supports the current API version (eSewa ePay v2 and Khalti’s ePayment API). Older plugins built on deprecated APIs break without warning.
- It verifies the payment server-side before marking the order paid (more on this below).
- It works with the block-based Checkout, which has been WooCommerce’s default checkout since version 8.3. Gateways that only support the classic shortcode checkout won’t appear there.
- It was updated recently and is tested with your WooCommerce version.
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
- Never trust the redirect alone. Anyone can visit your success URL with fake parameters. Always confirm the payment with the gateway’s own status or lookup API from your server.
- Check the amount. Confirm the verified amount matches the order total, so a customer can’t pay Rs 10 for a Rs 10,000 order.
- Make completion idempotent. Customers refresh pages and gateways retry. Check whether the order is already paid before calling
$order->payment_complete(). - Keep secret keys out of code. Store them in gateway settings or
wp-config.php, never in a theme or a public repo. - Handle the unhappy paths. Cancelled, pending, and expired payments should leave the order in a clear state the customer can retry from.
- Log every response. Use
wc_get_logger()so you can trace disputes and failed payments later.
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