Khalti Payment Gateway v2 Integration
Khalti provides a modern, RESTful checkout experience known as Khalti Payment Gateway v2 (KPG-2). Unlike form-based redirects, Khalti relies on server-to-server JSON API requests, returning a hosted payment page URL (payment_url) and a unique payment session identifier (pidx).
How Khalti ePayment v2 Works
Section titled “How Khalti ePayment v2 Works”1. Customer clicks "Pay with Khalti" └─ Your Backend sends authenticated POST to /epayment/initiate/ └─ Header: "Authorization: Key <secretKey>" └─ Khalti responds with: { pidx, payment_url, expires_at, expires_in }
2. Your Backend redirects Customer to payment_url └─ Customer enters Khalti mobile wallet number & MPIN └─ Khalti verifies SMS OTP and debits funds
3. Khalti redirects Customer back to your returnUrl └─ Appends query parameters: ?pidx=<pidx>&status=Completed&transaction_id=...
4. Your Backend executes S2S Verification └─ Sends POST to /epayment/lookup/ with { pidx } └─ Confirms status === "Completed" and total_amount === expectedAmountPaisa └─ Fulfills order and marks paid in databaseConfiguration & Credentials
Section titled “Configuration & Credentials”Payment credentials should be stored in environment variables and never exposed to client browsers.
To obtain your sandbox keys:
- Sign up for a free merchant test account at Khalti Test Merchant Admin.
- Log in and navigate to Keys in the left sidebar.
- Copy your test Secret Key (
live_secret_keyortest_secret_key).
import type { KhaltiConfig } from "@bivekkk/nepay";
export const khaltiConfig: KhaltiConfig = { secretKey: process.env.KHALTI_SECRET_KEY!, publicKey: process.env.KHALTI_PUBLIC_KEY, // Optional: for client widgets websiteUrl: process.env.APP_URL ?? "https://yourdomain.com", returnUrl: `${process.env.APP_URL ?? "https://yourdomain.com"}/api/payment/callback/khalti`, env: "sandbox", // "sandbox" | "production"};Configuration Parameters
Section titled “Configuration Parameters”| Property | Type | Requirement | Description |
|---|---|---|---|
secretKey |
string | Required | Merchant secret key used in Authorization: Key <secretKey> header. |
publicKey |
string | Optional | Public key used if initializing client-side SDKs. |
websiteUrl |
string | Required | Your canonical website homepage URL (must be valid HTTP/HTTPS). |
returnUrl |
string | Required | Endpoint where Khalti redirects the user upon completion or cancellation. |
env |
“sandbox” | “production” | Required | Switches between https://dev.khalti.com/api/v2 and https://khalti.com/api/v2. |
baseUrl |
string | Optional | Custom base URL override for proxying or mocked testing environments. |
Step 1: Initiating a Payment
Section titled “Step 1: Initiating a Payment”Call initiatePayment() or the standalone function initiateKhalti():
import { initiatePayment } from "@bivekkk/nepay";import { khaltiConfig } from "../lib/khalti.js";
const result = await initiatePayment({ method: "khalti", config: khaltiConfig, order: { orderId: "INV-2024-001", amountPaisa: 10000, // NPR 100.00 purchaseOrderName: "Store Order #001", customerInfo: { name: "Ram Sharma", email: "ram@example.com", phone: "9800000000", }, // Optional itemized breakdown (sum must equal amountPaisa) amountBreakdown: [ { label: "Items Total", amount: 9000 }, { label: "VAT (13%)", amount: 1000 }, ], },});
// Store result.pidx in your database associated with INV-2024-001console.log("Payment Index (pidx):", result.pidx);
// Redirect customer to Khalti hosted checkoutres.redirect(result.payment_url);Request Parameter Specifications
Section titled “Request Parameter Specifications”| Parameter | Type | Required | Description |
|---|---|---|---|
orderId |
string | Required | Unique internal identifier for the order (purchase_order_id). |
amountPaisa |
number | Required | Total payable amount in integer paisa. Minimum allowed is 1000 (NPR 10.00). |
purchaseOrderName |
string | Required | Human-readable title displayed on the Khalti checkout screen. |
customerInfo |
object | Optional | Details (name, email, phone) pre-filling the payment form. |
amountBreakdown |
Array<{label, amount}> | Optional | Itemized billing. The sum of item amounts must strictly equal amountPaisa. |
Gateway Response Structure
Section titled “Gateway Response Structure”Upon successful initiation, Khalti responds with:
{ "pidx": "53uiCQgY7Df2ZD7hmetofZ", "payment_url": "https://test-pay.khalti.com/?pidx=53uiCQgY7Df2ZD7hmetofZ", "expires_at": "2026-10-29T14:15:22.000000Z", "expires_in": 1800}Step 2: Customer Redirection & Payment
Section titled “Step 2: Customer Redirection & Payment”When your backend redirects the customer to payment_url:
- The customer enters their 10-digit Khalti mobile number.
- They enter their 4-digit MPIN.
- Khalti sends a 6-digit OTP via SMS.
- The customer confirms payment.
Step 3: Server-to-Server Verification
Section titled “Step 3: Server-to-Server Verification”When payment completes or is canceled, Khalti redirects back to your returnUrl:
https://yourdomain.com/api/payment/callback/khalti?pidx=53uiCQgY7Df2ZD7hmetofZ&status=Completed&transaction_id=...
import { verifyPayment } from "@bivekkk/nepay";import { khaltiConfig } from "../lib/khalti.js";
export async function handleKhaltiReturn(req, res) { const pidx = req.query.pidx as string;
const result = await verifyPayment({ method: "khalti", config: khaltiConfig, pidx, expectedOrderId: "INV-2024-001", expectedAmountPaisa: 10000, });
if (result.status === "completed") { // 1. Transaction confirmed by Khalti lookup endpoint // 2. Amount matched expectedAmountPaisa exactly // 3. Order ID verified await db.orders.update({ where: { id: result.orderId }, data: { status: "PAID", gatewayRef: result.traceId, // Khalti transaction_id }, });
return res.redirect(`/order/success?id=${result.orderId}`); }
// Handle non-completed statuses: 'pending' | 'failed' return res.redirect(`/order/failed?reason=${encodeURIComponent(result.status)}`);}Lookup Response Fields
Section titled “Lookup Response Fields”| Field | Type | Description |
|---|---|---|
pidx |
string | Unique identifier for the payment session. |
status |
string | Gateway state: "Completed", "Pending", "Initiated", "Expired", "User canceled". |
total_amount |
number | Total amount paid in integer paisa. |
transaction_id |
string | Settlement reference assigned once payment completes. |
fee |
number | Khalti processing fee in paisa. |
refunded |
boolean | Indicates whether the transaction has been refunded. |
Edge Case: Resilient HTTP 400 Expired Body Handling
Section titled “Edge Case: Resilient HTTP 400 Expired Body Handling”When a payment session is abandoned or expires, Khalti returns an HTTP 400 Bad Request status containing a JSON body:
{ "status": "Expired"}Many standard HTTP client libraries crash by throwing unhandled network exceptions on HTTP 400 responses.
nepay intercepts 400 responses, safely decodes the JSON body, and maps it directly to:
{ status: "failed", message: "Khalti transaction expired", raw: { status: "Expired" }}This prevents your server from crashing and allows you to present a graceful retry flow to the customer.
Sandbox Testing Credentials
Section titled “Sandbox Testing Credentials”To test Khalti integrations in the sandbox environment:
- Merchant Portal: Register your test merchant account at Khalti Test Merchant Admin to generate your test Secret Key.
- API Base URL:
https://dev.khalti.com/api/v2 - Customer Test Wallets:
9800000000through9800000005 - Test MPIN:
1111 - Test OTP:
987654