Skip to content
Payments⏱️ 12 min read·

Khalti ePayment v2 API in Node.js/Express: Initiate, Verify, and Handle Callbacks

Build a complete Khalti ePayment v2 checkout flow in Node.js and Express. Learn how to initiate payments with Bearer auth, obtain the payment URL (pidx), handle callbacks safely, and lookup transaction status on the server.

Need this wired into a product? See payment gateway integration.

MN
Manoj NeupaneSoftware Developer & Programming Instructor
#Khalti#Node.js#Express#MERN Stack#Payment Gateway#pidx
🎯 Who This Tutorial Is For

📋 Prerequisites
  • ✓Node.js (v18+) and npm installed on your development machine
  • ✓An Express server configured with JSON body parsing
  • ✓Khalti sandbox test secret key from test-admin.khalti.com

🚀 What You Will Learn & Build

  • ▸How to call Khalti /epayment/initiate/ endpoint with secret Bearer authorization
  • ▸Extracting and storing the pidx (Payment Identifier) in your database
  • ▸Redirecting users to Khalti checkout and capturing return_url query parameters
  • ▸Calling Khalti /epayment/lookup/ server-to-server for trusted payment confirmation
  • ▸Handling idempotency and cancelled or failed transactions gracefully
1

1. The Khalti ePayment v2 Flow

Unlike older widget popups, Khalti's **ePayment v2 API** is a modern, server-driven flow: 1. **Initiate:** Your Express server POSTs order details to `https://dev.khalti.com/api/v2/epayment/initiate/`. 2. **Response:** Khalti returns a unique `pidx` (payment identifier) and a `payment_url`. 3. **Store & Redirect:** Your server stores `pidx` with your order and redirects the customer to `payment_url`. 4. **Return:** After approval, Khalti redirects the browser to your `return_url` with query params (`pidx`, `status`, `transaction_id`). 5. **Lookup & Fulfill:** Your server calls `/epayment/lookup/` using `pidx` to confirm payment before delivering digital goods.
2

2. Full Node.js Express Controller Implementation

Here is the complete Express router implementation using native `fetch` (Node 18+):
routes/khalti.js
const express = require("express");
const router = express.Router();

const KHALTI_API_URL = "https://dev.khalti.com/api/v2/epayment";
const KHALTI_SECRET_KEY = process.env.KHALTI_SECRET_KEY || "key test_secret_key_xxxx";

/**
 * 1. INITIATE PAYMENT
 */
router.post("/initiate", async (req, res) => {
  try {
    const { orderId, amount, customerInfo } = req.body;

    // Amount must be in Paisa (1 NPR = 100 Paisa). Minimum is 1000 Paisa (NPR 10)
    const amountInPaisa = Math.round(Number(amount) * 100);

    const payload = {
      return_url: "http://localhost:5000/api/payment/khalti/callback",
      website_url: "http://localhost:3000",
      amount: amountInPaisa,
      purchase_order_id: orderId || `ORDER_${Date.now()}`,
      purchase_order_name: "Web Development Course Enrollment",
      customer_info: customerInfo || {
        name: "Test Developer",
        email: "developer@example.com",
        phone: "9800000000"
      }
    };

    const response = await fetch(`${KHALTI_API_URL}/initiate/`, {
      method: "POST",
      headers: {
        "Authorization": `Key ${KHALTI_SECRET_KEY}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(payload)
    });

    const data = await response.json();

    if (!response.ok) {
      return res.status(response.status).json({ error: data });
    }

    // Save data.pidx with your database order record here
    return res.json({
      success: true,
      pidx: data.pidx,
      payment_url: data.payment_url
    });
  } catch (error) {
    console.error("Khalti initiation error:", error);
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * 2. HANDLE CALLBACK & VERIFY
 */
router.get("/callback", async (req, res) => {
  try {
    const { pidx, status, transaction_id, message } = req.query;

    if (!pidx) {
      return res.redirect("http://localhost:3000/checkout?status=failed&reason=no_pidx");
    }

    // Verify status server-side using Khalti lookup endpoint
    const lookupResponse = await fetch(`${KHALTI_API_URL}/lookup/`, {
      method: "POST",
      headers: {
        "Authorization": `Key ${KHALTI_SECRET_KEY}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ pidx })
    });

    const verificationData = await lookupResponse.json();

    if (lookupResponse.ok && verificationData.status === "Completed") {
      // Mark order as PAID in MongoDB / PostgreSQL
      // await Order.updateOne({ pidx }, { status: 'PAID', transactionId: transaction_id });

      return res.redirect(`http://localhost:3000/order-success?pidx=${pidx}&txn=${transaction_id}`);
    }

    return res.redirect(`http://localhost:3000/checkout?status=failed&message=${encodeURIComponent(message || "Payment not completed")}`);
  } catch (error) {
    console.error("Khalti callback error:", error);
    return res.redirect("http://localhost:3000/checkout?status=error");
  }
});

module.exports = router;

✨ Expected Output & Verification

A secure RESTful checkout API that returns the official Khalti checkout URL and verifies payment status server-side on completion.

🧪 How to Test the Implementation

  1. 1Send a POST request to /api/payment/khalti/initiate with amount: 100
  2. 2Copy payment_url from JSON response and open in browser
  3. 3Log into Khalti sandbox with test wallet number '9800000000' and MPIN '1111'
  4. 4Enter test confirmation OTP '987654'
  5. 5Observe redirect to your callback and server status verification as 'Completed'

⚠️ Common Errors & How to Fix Them

❌401 Unauthorized from Khalti API

Root Cause: Missing or incorrect Authorization header format. It must be 'Key <secret_key>', not 'Bearer <secret_key>'.

Fix: Format header exactly as: Authorization: Key your_khalti_live_or_test_secret_key.

❌Invalid Amount Error

Root Cause: Passing amount in Rupees instead of Paisa, or sending an amount below NPR 10 (1000 Paisa).

Fix: Multiply your NPR amount by 100: const amountInPaisa = Math.round(npr * 100).

🛡️ Security Considerations & Best Practices

  • !Never expose your Khalti Secret Key in React, Vue, or frontend mobile bundles.
  • !Always query the /lookup/ endpoint before marking orders paid in your database.

❓ Frequently Asked Questions (FAQ)

Yes. For user redirects, localhost:3000 works in the browser. For server-to-server callbacks, you can test directly or use ngrok if you need public webhooks.

📌 Summary of Key Takeaways

Khalti ePayment v2 provides a clean JSON API. Convert amounts to Paisa, use 'Key <secret>' auth headers, and always verify with /lookup/ server-to-server.

Official References & Further Reading