Skip to content
Payments⏱️ 14 min read·

How to Integrate Khalti Payment Gateway in Node.js and Laravel (ePayment API v2)

Step-by-step tutorial on integrating Khalti ePayment v2 in your web applications. Learn payment initiation, pidx token handling, checkout redirect, and server-side transaction lookup.

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

MN
Manoj NeupaneSoftware Developer & Programming Instructor
#Khalti#Payments#Node.js#Laravel#React#Nepal
🎯 Who This Tutorial Is For

Developers in Nepal integrating digital payment processing with Khalti's modern REST ePayment API.

📋 Prerequisites
  • ✓Node.js or Laravel environment
  • ✓Understanding of Bearer / Key authorization headers
  • ✓Khalti merchant account or test sandbox keys

🚀 What You Will Learn & Build

  • ▸How Khalti's modern ePayment v2 workflow functions
  • ▸Initiating payments using the /epayment/initiate/ endpoint
  • ▸Understanding the payment identifier (pidx)
  • ▸Redirecting users to payment_url securely
  • ▸Verifying payment status using /epayment/lookup/
  • ▸Handling edge cases like canceled payments and duplicate callbacks
1

1. Understanding Khalti ePayment v2 Architecture

Khalti's v2 ePayment API is developer-friendly, utilizing standard JSON requests and authorization keys. The workflow is: 1. **Initiate:** Your backend sends a JSON payload (`amount` in Paisa, `return_url`, `purchase_order_id`) to `https://a.khalti.com/api/v2/epayment/initiate/`. 2. **PIDX & Payment URL:** Khalti returns a unique payment identifier `pidx` and a hosted `payment_url`. 3. **Redirect:** You redirect the user to the provided `payment_url`. 4. **Callback:** Once paid or canceled, Khalti redirects back to your `return_url` with query parameters (`pidx`, `status`, `transaction_id`). 5. **Lookup & Verification:** Your server calls `https://a.khalti.com/api/v2/epayment/lookup/` with the `pidx` to confirm the status is `Completed` before fulfilling the order.
2

2. Khalti Sandbox Credentials & Configuration

Khalti sandbox requires a test secret key. Test mode operates on `https://a.khalti.com/api/v2/`, while production uses `https://khalti.com/api/v2/`. **Important Unit Warning:** Khalti expects all amounts in **Paisa** (1 NPR = 100 Paisa). If your item is Rs. 150, you must submit `15000`.
.env
# Khalti Credentials
KHALTI_SECRET_KEY=live_secret_key_68791341fdd94846a146f0457f9b6e1b # Replace with your test/live key
KHALTI_API_BASE=https://a.khalti.com/api/v2
APP_URL=http://localhost:3000
3

3. Initiating the Payment (Server-Side)

Never call Khalti's initiate API directly from client-side JavaScript because doing so exposes your secret authorization key. Always invoke it from your backend.
const axios = require('axios');

async function initiateKhaltiPayment({ returnUrl, websiteUrl, amountNpr, purchaseOrderId, purchaseOrderName, customerInfo }) {
  const url = `${process.env.KHALTI_API_BASE}/epayment/initiate/`;
  
  // Convert NPR to Paisa (Rs 10 = 1000 Paisa)
  const amountInPaisa = Math.round(Number(amountNpr) * 100);

  const payload = {
    return_url: returnUrl,
    website_url: websiteUrl,
    amount: amountInPaisa,
    purchase_order_id: purchaseOrderId,
    purchase_order_name: purchaseOrderName,
    customer_info: customerInfo || {
      name: "Test Customer",
      email: "test@example.com",
      phone: "9800000000"
    }
  };

  const response = await axios.post(url, payload, {
    headers: {
      Authorization: `Key ${process.env.KHALTI_SECRET_KEY}`,
      'Content-Type': 'application/json'
    }
  });

  // response.data contains: { pidx: "...", payment_url: "https://...", expires_at: "..." }
  return response.data;
}

module.exports = { initiateKhaltiPayment };
4

4. Verifying Payment with the Lookup API

When the payment completes, Khalti sends the user back to your `return_url` with parameters: `?pidx=bF8...&status=Completed&transaction_id=123...&amount=1000` Never trust the query parameters alone. Use your secret key to query the lookup endpoint to verify the transaction status.
router.get('/khalti/callback', async (req, res) => {
  const { pidx, status, transaction_id } = req.query;

  if (!pidx) return res.status(400).send('Missing pidx in callback');

  try {
    const lookupRes = await axios.post(
      `${process.env.KHALTI_API_BASE}/epayment/lookup/`,
      { pidx },
      {
        headers: {
          Authorization: `Key ${process.env.KHALTI_SECRET_KEY}`,
          'Content-Type': 'application/json'
        }
      }
    );

    const data = lookupRes.data;

    if (data.status === 'Completed') {
      // 1. Verify data.total_amount matches expected order amount
      // 2. Mark order as Paid in database
      // 3. Prevent duplicate fulfillment
      return res.redirect(`/order-success?pidx=${pidx}`);
    } else {
      return res.redirect(`/order-failed?status=${data.status}`);
    }
  } catch (error) {
    console.error('Khalti lookup error:', error.response?.data || error.message);
    return res.status(500).send('Payment verification failed');
  }
});

✨ Expected Output & Verification

Successful initiation returns: { "pidx": "bF8t25Xq937gM", "payment_url": "https://test-pay.khalti.com/?pidx=bF8t25Xq937gM", "expires_at": "2025-01-12T10:15:30Z", "expires_in": 1800 } Successful lookup returns: { "pidx": "bF8t25Xq937gM", "total_amount": 1000, "status": "Completed", "transaction_id": "T1234567890", "fee": 0, "refunded": false }

🧪 How to Test the Implementation

  1. 1Use Khalti test wallet credentials provided in Khalti docs (Mobile: 9800000000 to 9800000005, MPIN: 1111, OTP: 987654)
  2. 2Initiate payment and verify browser redirects to https://test-pay.khalti.com
  3. 3Enter credentials and confirm checkout
  4. 4Verify that return_url receives pidx and server completes lookup successfully

⚠️ Common Errors & How to Fix Them

❌HTTP 401 Unauthorized

Root Cause: Missing or incorrect 'Key <secret_key>' header in authorization request.

Fix: Format the header as 'Authorization: Key your_secret_key_here' with a space after 'Key'.

❌Invalid amount or amount too low

Root Cause: Submitting rupees instead of paisa (e.g. submitting 10 instead of 1000).

Fix: Multiply rupee amounts by 100 and cast to an integer before sending payload.

❌Expired PIDX

Root Cause: User stayed on checkout screen longer than 30 minutes.

Fix: Handle Expired status by generating a fresh payment session.

🛡️ Security Considerations & Best Practices

  • !Never put the Khalti secret key inside client-side JS or HTML.
  • !Verify the transaction amount returned by /lookup/ matches your database order value in Paisa.
  • !Implement idempotent database operations to prevent crediting orders twice.

❓ Frequently Asked Questions (FAQ)

Yes, Khalti's hosted payment page allows users to pay via Khalti Wallet, ConnectIPS, Mobile Banking (all major Nepali banks), and SCT / Visa cards.

📌 Summary of Key Takeaways

  • ✓Khalti v2 uses simple JSON REST endpoints: /initiate/ and /lookup/.
  • ✓Always supply amounts in Paisa (NPR * 100).
  • ✓Perform server-to-server verification with the pidx before marking orders as paid.

Official References & Further Reading