- ✓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. The Khalti ePayment v2 Flow
2. Full Node.js Express Controller Implementation
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
- 1Send a POST request to /api/payment/khalti/initiate with amount: 100
- 2Copy payment_url from JSON response and open in browser
- 3Log into Khalti sandbox with test wallet number '9800000000' and MPIN '1111'
- 4Enter test confirmation OTP '987654'
- 5Observe redirect to your callback and server status verification as 'Completed'
⚠️ Common Errors & How to Fix Them
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.
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
Written by Manoj Neupane
AuthorSoftware developer and programming instructor, Nepal
Building software systems and SaaS platforms with Laravel, Next.js, and Node.js. Passionate about helping students and developers in Nepal master practical, real-world development skills.