Skip to content

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).


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 database

Payment credentials should be stored in environment variables and never exposed to client browsers.

To obtain your sandbox keys:

  1. Sign up for a free merchant test account at Khalti Test Merchant Admin.
  2. Log in and navigate to Keys in the left sidebar.
  3. Copy your test Secret Key (live_secret_key or test_secret_key).
src/lib/khalti.ts
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"
};
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.

Call initiatePayment() or the standalone function initiateKhalti():

src/routes/checkout.ts
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-001
console.log("Payment Index (pidx):", result.pidx);
// Redirect customer to Khalti hosted checkout
res.redirect(result.payment_url);
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.

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
}

When your backend redirects the customer to payment_url:

  1. The customer enters their 10-digit Khalti mobile number.
  2. They enter their 4-digit MPIN.
  3. Khalti sends a 6-digit OTP via SMS.
  4. The customer confirms payment.

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=...

src/routes/callback.ts
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)}`);
}
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.


To test Khalti integrations in the sandbox environment:

  1. Merchant Portal: Register your test merchant account at Khalti Test Merchant Admin to generate your test Secret Key.
  2. API Base URL: https://dev.khalti.com/api/v2
  3. Customer Test Wallets: 9800000000 through 9800000005
  4. Test MPIN: 1111
  5. Test OTP: 987654