Documentation · itzsa
Nepal Pay
Unified TypeScript SDK for eSewa (ePay v2) and Khalti (KPG-2). Server-side verification is mandatory — a browser redirect is never treated as proof of payment.
Unofficial
Not affiliated with or endorsed by eSewa (F1Soft) or Khalti. Merchant agreements and credentials stay between you and the providers. eSewa docs · Khalti docs.Installation#
Zero runtime dependencies. Uses Node crypto + native fetch.
pnpm add @itzsa/nepal-payQuick start#
Prefer PaymentService — it wires initiate, store, and verify-on-return.
import {
createNepalPay,
createPaymentService,
MemoryPaymentStore,
} from "@itzsa/nepal-pay";
const pay = createNepalPay({
mode: "sandbox",
timeoutMs: 15_000,
retries: 1,
esewa: {
productCode: "EPAYTEST",
secretKey: process.env.ESEWA_SECRET!, // UAT: 8gBm/:&EnhH.1/q (no trailing '(')
},
khalti: {
secretKey: process.env.KHALTI_SECRET!,
},
});
const store = new MemoryPaymentStore(); // swap for PrismaPaymentStore in prod
const khalti = pay.gateway("khalti");
const service = createPaymentService(khalti, store, {
successUrl: "https://example.com/pay/success",
failureUrl: "https://example.com/pay/failed",
onConfirmed: async (paymentId) => {
await fulfillOrder(paymentId); // runs at most once
},
});
const { initiate, record } = await service.start({
amount: 10.5, // NPR — never paisa at the public API
orderId: "order-42",
orderName: "Pro plan",
returnUrl: "https://example.com/pay/khalti/return",
websiteUrl: "https://example.com",
});
// Redirect user to initiate.redirectUrlTest forms#
One checkout form for both gateways — same fields, arrow flow, full code always visible, then pay → return → verify.
Complete a transaction
Pick eSewa or Khalti, fill the form, generate/initiate, then follow the pay button. Returns land on/nepal-pay/return (eSewa) or /nepal-pay/khalti-return (Khalti) for server-side verify. Khalti mock mode still runs the full return → lookup path; paste a sandbox secret and uncheck mock for a live payment page.Unified checkout#
Name, amount, reference, URLs, tax/service/delivery, gateway toggle — matches a real merchant form.
eSewa UAT test credentials
- eSewa ID 9711111111 (also 9711111112–14)
- Password Nepal@123
- Token / OTP 123456
- Product EPAYTEST
- Secret 8gBm/:&EnhH.1/q (no trailing `(`)
- 1
Generate signed form
Server HMAC-SHA256 over total_amount,transaction_uuid,product_code → Base64 signature.
- 2
HTML form POST
Browser POSTs hidden fields to https://rc-epay.esewa.com.np/api/epay/main/v2/form
- 3
Pay on eSewa UAT
Login 9711111111 / Nepal@123 / token 123456 — confirm payment.
- 4
Redirect to success_url
eSewa appends ?data=<base64 JSON> (signed callback — still untrusted alone).
- 5
Verify signature + status API
Recompute HMAC, then GET status — only COMPLETE means safe to fulfill.
Full code (always visible)
// Unified checkout → esewa
const payload = {
amount: 100,
taxAmount: 0,
serviceCharge: 0,
deliveryCharge: 0,
orderId: "ORD-MRXOGSQA",
orderName: "Order payment",
customerName: "Suman Acharya",
returnUrl: "http://localhost:3000/nepal-pay/return",
failureUrl: "http://localhost:3000/nepal-pay/return?failed=1",
};
// eSewa: signed HTML POST
const res = await fetch("/api/nepal-pay/esewa/initiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const { initiate } = await res.json();
// <form method="POST" action={initiate.redirectUrl}>…formFields…</form>
// → /nepal-pay/return?data=… → verify signature + statusIntegration outline
// One form → eSewa or Khalti
// Fields: name, amount, orderId, description, success/failure URLs,
// tax, service, delivery → total
//
// eSewa → POST /api/nepal-pay/esewa/initiate → HTML form POST → /nepal-pay/return
// Khalti → POST /api/nepal-pay/khalti/initiate → payment_url → /nepal-pay/khalti-return
//
// eSewa UAT: 9711111111 / Nepal@123 / 123456 · secret 8gBm/:&EnhH.1/q
// Khalti: 9800000000–05 / MPIN 1111 / OTP 987654 · merchant test secretFlow comparison#
Arrow paths for both gateways.
eSewa
- 1
Generate signed form
Server HMAC-SHA256 over total_amount,transaction_uuid,product_code → Base64 signature.
- 2
HTML form POST
Browser POSTs hidden fields to https://rc-epay.esewa.com.np/api/epay/main/v2/form
- 3
Pay on eSewa UAT
Login 9711111111 / Nepal@123 / token 123456 — confirm payment.
- 4
Redirect to success_url
eSewa appends ?data=<base64 JSON> (signed callback — still untrusted alone).
- 5
Verify signature + status API
Recompute HMAC, then GET status — only COMPLETE means safe to fulfill.
Khalti
- 1
Initiate (JSON)
POST /epayment/initiate/ with amount in paisa (NPR × 100). Auth: Key <secret>.
- 2
Redirect to payment_url
Browser GET to test-pay.khalti.com/?pidx=… (expires ~30–60 min).
- 3
Pay on Khalti sandbox
Khalti ID 9800000000–9800000005 · MPIN 1111 · OTP 987654.
- 4
Redirect to return_url
Query params: pidx, status, amount, … — no signature; never trust alone.
- 5
Lookup verify
POST /epayment/lookup/ { pidx } — only status Completed means deliver service.
Responses#
Every good and error payload you should handle — gateway upstream shapes and typed SDK results.
Explorer#
Filter by gateway and success / error / info.
method POST + formFields — render an HTML form, do not GET this URL.
eSewa initiate (SDK) · success
{
"ok": true,
"gateway": "esewa",
"mode": "sandbox",
"initiate": {
"redirectUrl": "https://rc-epay.esewa.com.np/api/epay/main/v2/form",
"providerRef": "241028",
"method": "POST",
"formFields": {
"amount": "110",
"tax_amount": "0",
"product_service_charge": "0",
"product_delivery_charge": "0",
"total_amount": "110",
"transaction_uuid": "241028",
"product_code": "EPAYTEST",
"success_url": "https://example.com/pay/esewa/return",
"failure_url": "https://example.com/pay/failed",
"signed_field_names": "total_amount,transaction_uuid,product_code",
"signature": "i94zsd3oXF6ZsSr/kGqT4sSzYQzjj1W/waxjWyRwaME="
}
}
}eSewa payloads#
Initiate (SDK), callback (decoded), status API, and signature errors.
Good — initiate
{
"ok": true,
"gateway": "esewa",
"mode": "sandbox",
"initiate": {
"redirectUrl": "https://rc-epay.esewa.com.np/api/epay/main/v2/form",
"providerRef": "241028",
"method": "POST",
"formFields": {
"amount": "110",
"tax_amount": "0",
"product_service_charge": "0",
"product_delivery_charge": "0",
"total_amount": "110",
"transaction_uuid": "241028",
"product_code": "EPAYTEST",
"success_url": "https://example.com/pay/esewa/return",
"failure_url": "https://example.com/pay/failed",
"signed_field_names": "total_amount,transaction_uuid,product_code",
"signature": "i94zsd3oXF6ZsSr/kGqT4sSzYQzjj1W/waxjWyRwaME="
}
}
}Good — callback (decoded data param)
{
"transaction_code": "000AWEO",
"status": "COMPLETE",
"total_amount": 1000.0,
"transaction_uuid": "250610-162413",
"product_code": "EPAYTEST",
"signed_field_names": "transaction_code,status,total_amount,transaction_uuid,product_code,signed_field_names",
"signature": "<hmac-base64>"
}Good — status COMPLETE (only paid status)
{
"product_code": "EPAYTEST",
"transaction_uuid": "240508-10108",
"total_amount": 100.0,
"status": "COMPLETE",
"ref_id": "0007G36"
}Error — status CANCELED
{
"product_code": "EPAYTEST",
"transaction_uuid": "240508-102939",
"total_amount": 10.0,
"status": "CANCELED",
"ref_id": "0KDL6NA"
}Error — SignatureMismatchError
{
"ok": false,
"error": {
"code": "SIGNATURE_MISMATCH",
"name": "SignatureMismatchError",
"message": "eSewa callback signature mismatch — possible tampering"
}
}| Status | Type | Default | Description |
|---|---|---|---|
| COMPLETE | → confirmed | — | Only status meaning paid. |
| PENDING / AMBIGUOUS | → pending | — | Hold; do not fulfill. |
| CANCELED / NOT_FOUND | → failed | — | Not paid. |
| FULL_REFUND | → refunded | — | Full refund. |
| PARTIAL_REFUND | → partially_refunded | — | Partial refund. |
Khalti payloads#
Initiate request/response, untrusted callback, lookup, and API errors.
Request — initiate (paisa)
{
"return_url": "https://example.com/pay/khalti/return",
"website_url": "https://example.com",
"amount": 1050,
"purchase_order_id": "order-42",
"purchase_order_name": "Pro plan",
"customer_info": {
"name": "Test User",
"email": "test@example.com",
"phone": "9800000000"
}
}Good — initiate success
{
"pidx": "bZQLD9wbdAi789cZ5GvUdF",
"payment_url": "https://test-pay.khalti.com/?pidx=bZQLD9wbdAi789cZ5GvUdF",
"expires_at": "2026-07-23T12:30:00.000000+05:45",
"expires_in": 1800
}Info — callback Completed (untrusted alone)
{
"pidx": "bZQLD9wbdAi789cZ5GvUdF",
"txnId": "4d5kjnABpA2n7LLxLh9cDP",
"amount": 1050,
"total_amount": 1050,
"status": "Completed",
"mobile": "98XXXXX000",
"purchase_order_id": "order-42",
"purchase_order_name": "Pro plan",
"transaction_id": "4d5kjnABpA2n7LLxLh9cDP"
}Good — lookup Completed (deliver service)
{
"pidx": "bZQLD9wbdAi789cZ5GvUdF",
"total_amount": 1050,
"status": "Completed",
"transaction_id": "4d5kjnABpA2n7LLxLh9cDP",
"fee": 0,
"refunded": false
}Error — 401 Invalid token
{
"ok": false,
"error": {
"code": "GATEWAY_API",
"name": "GatewayApiError",
"message": "Lookup failed with HTTP 401",
"gateway": "khalti",
"statusCode": 401,
"body": { "detail": "Invalid token." }
}
}Error — 400 validation
{
"ok": false,
"error": {
"code": "GATEWAY_API",
"name": "GatewayApiError",
"message": "Initiate failed with HTTP 400",
"gateway": "khalti",
"statusCode": 400,
"body": {
"amount": ["Amount should be greater than 1000 (Rs. 10)."],
"return_url": ["This field is required."]
}
}
}| Status | Type | Default | Description |
|---|---|---|---|
| Completed | → confirmed | — | Only status meaning paid. |
| Pending / Initiated | → pending | — | Hold; contact Khalti if stuck. |
| Expired / User canceled | → failed | — | Not paid. |
| Refunded | → refunded | — | Full refund. |
| Partially refunded | → partially_refunded | — | Partial refund. |
SDK results & errors#
Normalized shapes from handleCallback / verify / return handler.
Callback received (never confirmed)
{
"kind": "callback_received",
"providerRef": "bZQLD9wbdAi789cZ5GvUdF",
"raw": { "pidx": "bZQLD9wbdAi789cZ5GvUdF", "status": "Completed" }
}Verification confirmed
{
"status": "confirmed",
"providerRef": "bZQLD9wbdAi789cZ5GvUdF",
"amount": 10.5,
"transactionId": "4d5kjnABpA2n7LLxLh9cDP",
"raw": { "status": "Completed", "total_amount": 1050 }
}Verification failed
{
"status": "failed",
"providerRef": "bZQLD9wbdAi789cZ5GvUdF",
"amount": 10.5,
"raw": { "status": "User canceled", "total_amount": 1050 }
}| Class | Type | Default | Description |
|---|---|---|---|
| ConfigError | CONFIG | — | Missing keys, invalid amount, bad constructor options. |
| SignatureMismatchError | SIGNATURE_MISMATCH | — | eSewa callback HMAC did not match — treat as tampering. |
| VerificationFailedError | VERIFICATION_FAILED | — | verify() did not confirm payment. |
| GatewayApiError | GATEWAY_API | — | Upstream HTTP / network / timeout. Includes statusCode + body. |
| InvalidTransitionError | INVALID_TRANSITION | — | Illegal state machine move (e.g. callback → confirmed). |
| RefundNotSupportedError | REFUND_NOT_SUPPORTED | — | refund() not integrated in v1. |
Architecture#
Backend-first, framework-agnostic. Gateways are adapters behind one interface.
packages/nepal-pay/src
core/ types, errors, state machine, amount helpers
gateways/ esewa/ · khalti/ (+ future folders)
registry/ registerGateway() for plugins
store/ PaymentStore + Memory + Prisma reference
flow/ PaymentService orchestrator
webhook/ createReturnUrlHandler (framework-agnostic)
http/ fetchJson (timeout + retries)
index.ts public API onlyState machine#
TypeScript + runtime enforcement. handleCallback cannot produce confirmed.
| Status | Type | Default | Description |
|---|---|---|---|
| pending | initial | — | Created after initiate; awaiting user return. |
| callback_received | from handleCallback | — | Browser returned — still untrusted. |
| verifying | before verify() | — | Server-side check in progress. |
| confirmed | from verify() only | — | Safe to fulfill the order. |
| failed | cancel / verify fail | — | Do not deliver. |
| refunded | post-confirm | — | Mapped from gateway refund statuses. |
After initiate() — user is on the gateway page
- Payment created → pending
Amount units#
Public API is always NPR decimal. Khalti paisa conversion is internal.
100× bug
eSewa uses NPR decimals. Khalti uses paisa (NPR × 100). Passing10.50 to initiate sends 1050 paisa to Khalti — never ask your app code to convert.Idempotency#
Unique (gateway, providerRef). Confirming twice is a no-op.
updateStatus returns { record, changed }. Already-confirmed → confirmed sets changed: false. The return-URL handler only runs onConfirmed when changed is true, so double webhooks / double-clicks grant access once.
Examples#
Copy-paste patterns for both gateways and common frameworks.
Checkout flow#
start → redirect → return URL → verify → fulfill.
- Call
service.start()— persistspending. - Send the user to
initiate.redirectUrl(GET for Khalti, auto-POST form for eSewa). - On return, call
service.handleReturn(query). - Fulfill only inside
onConfirmed.
eSewa form POST#
const esewa = pay.gateway("esewa");
const { initiate } = await service.start({ /* … */ });
// eSewa is HTML form POST — auto-submit on the server or client:
const html = `<!doctype html><html><body>
<form id="esewa" action="${initiate.redirectUrl}" method="POST">
${Object.entries(initiate.formFields!)
.map(([k, v]) => `<input type="hidden" name="${k}" value="${v}" />`)
.join("")}
</form>
<script>document.getElementById("esewa").submit()</script>
</body></html>`;Khalti redirect#
const { initiate } = await service.start({
amount: 10.5, // → 1050 paisa inside KhaltiGateway
orderId: "order-42",
orderName: "Pro plan",
returnUrl: "https://example.com/pay/khalti/return",
websiteUrl: "https://example.com",
});
// 302 / client navigate to initiate.redirectUrl (GET)
res.redirect(initiate.redirectUrl);Return URL handler#
import { createReturnUrlHandler } from "@itzsa/nepal-pay";
const handleReturn = createReturnUrlHandler(gateway, store, {
successUrl: "https://example.com/pay/success",
failureUrl: "https://example.com/pay/failed",
onConfirmed: async (id) => fulfillOrder(id),
});
// 1) handleCallback (untrusted)
// 2) cancel → failed
// 3) callback_received → verifying → verify()
// 4) confirmed (idempotent) → successUrl
// 5) else → failureUrlExpress#
import express from "express";
import { createNepalPay, createPaymentService, MemoryPaymentStore } from "@itzsa/nepal-pay";
const app = express();
const pay = createNepalPay({ /* config */ });
const store = new MemoryPaymentStore();
const service = createPaymentService(pay.gateway("khalti"), store, {
successUrl: "https://example.com/ok",
failureUrl: "https://example.com/fail",
onConfirmed: fulfillOrder,
});
app.get("/pay/khalti/return", async (req, res) => {
const query = Object.fromEntries(
Object.entries(req.query).map(([k, v]) => [k, String(v)]),
);
const { redirectTo } = await service.handleReturn(query);
res.redirect(redirectTo);
});Next.js App Router#
// app/api/pay/khalti/return/route.ts
import { createNepalPay, createPaymentService, MemoryPaymentStore } from "@itzsa/nepal-pay";
const pay = createNepalPay({ /* config from env */ });
const store = new MemoryPaymentStore(); // use a shared store in real apps
const service = createPaymentService(pay.gateway("khalti"), store, {
successUrl: "https://example.com/ok",
failureUrl: "https://example.com/fail",
onConfirmed: fulfillOrder,
});
export async function GET(request: Request) {
const url = new URL(request.url);
const query = Object.fromEntries(url.searchParams.entries());
const { redirectTo } = await service.handleReturn(query);
return Response.redirect(redirectTo);
}Package API#
Full public surface. Types are exported alongside values.
Config & factories#
| Name | Type | Default | Description |
|---|---|---|---|
| mode | 'sandbox' | 'production' | — | Selects gateway base URLs. No env-var magic inside the library. |
| esewa.productCode | string | — | Merchant product code (UAT: EPAYTEST). |
| esewa.secretKey | string | — | HMAC secret from eSewa. Consumer loads secrets; SDK does not. |
| khalti.secretKey | string | — | Live/test secret. Sent as Authorization: Key <secret>. |
| timeoutMs | number | 15000 | Abort gateway HTTP calls after this many ms. |
| retries | number | 1 | Extra attempts on network / 5xx / 429 (not on 4xx). |
PaymentRequest
| Field | Type | Default | Description |
|---|---|---|---|
| amount | number | — | NPR decimal at the public API (e.g. 10.50). Never paisa. |
| orderId | string | — | Unique merchant order / invoice id. |
| orderName | string | — | Human label (required by Khalti). |
| returnUrl | string | — | Absolute success / return URL. |
| websiteUrl | string | — | Merchant site URL (required by Khalti). |
| failureUrl | string? | — | eSewa failure redirect; defaults to returnUrl. |
| taxAmount / serviceCharge / deliveryCharge | number? | 0 | eSewa breakdown; total must equal amount + these. |
| customer | { name?, email?, phone? }? | — | Optional Khalti customer_info. |
| metadata | Record<string, string>? | — | Opaque fields. merchant_* echoed by Khalti; transaction_uuid override for eSewa. |
PaymentGateway#
| Method | Type | Default | Description |
|---|---|---|---|
| initiate | (req) => Promise<InitiateResult> | — | Start payment. Khalti → payment_url (GET). eSewa → form action + formFields (POST). |
| handleCallback | (query) => Promise<CallbackResult> | — | Parse return-URL params. Return type has NO confirmed variant — untrusted. |
| verify | (providerRef, context?) => Promise<VerificationResult> | — | ONLY path that may yield confirmed. eSewa: signature + status API. Khalti: lookup. |
| refund | (providerRef, amount?) => Promise<RefundResult> | — | v1 throws RefundNotSupportedError — use gateway dashboards. |
PaymentService#
| Method | Type | Default | Description |
|---|---|---|---|
| start | (req) => Promise<{ record, initiate }> | — | initiate() + store.create(pending) in one call. |
| handleReturn | (query) => Promise<ReturnUrlHandlerResult> | — | callback → verifying → verify → idempotent confirm + optional onConfirmed. |
PaymentStore#
| Method | Type | Default | Description |
|---|---|---|---|
| create | (input) => Promise<PaymentRecord> | — | Must enforce unique (gateway, providerRef). |
| findByProviderRef | (gateway, providerRef) => Promise<PaymentRecord | null> | — | Lookup used by the return-URL handler. |
| updateStatus | (id, status) => Promise<{ record, changed }> | — | confirmed→confirmed is a no-op with changed: false (idempotent). |
Errors#
| Class | Type | Default | Description |
|---|---|---|---|
| ConfigError | CONFIG | — | Missing keys, invalid amount, bad constructor options. |
| SignatureMismatchError | SIGNATURE_MISMATCH | — | eSewa callback HMAC did not match — treat as tampering. |
| VerificationFailedError | VERIFICATION_FAILED | — | verify() did not confirm payment. |
| GatewayApiError | GATEWAY_API | — | Upstream HTTP / network / timeout. Includes statusCode + body. |
| InvalidTransitionError | INVALID_TRANSITION | — | Illegal state machine move (e.g. callback → confirmed). |
| RefundNotSupportedError | REFUND_NOT_SUPPORTED | — | refund() not integrated in v1. |
eSewa (ePay v2)#
Form POST initiate, signed callback, status check API.
Signature#
HMAC-SHA256 over total_amount=…,transaction_uuid=…,product_code=… (order is load-bearing), Base64 output. Callback signatures use the order in signed_field_names.
Status API mapping#
| Gateway status | Type | Default | Description |
|---|---|---|---|
| COMPLETE | → confirmed | — | Only status meaning paid. |
| PENDING / AMBIGUOUS | → pending | — | Hold; do not fulfill. |
| CANCELED / NOT_FOUND | → failed | — | Not paid. |
| FULL_REFUND | → refunded | — | Full refund. |
| PARTIAL_REFUND | → partially_refunded | — | Partial refund. |
Docs divergence#
ES104 — Invalid payload signature
Some eSewa doc pages print the UAT secret as8gBm/:&EnhH.1/q( (trailing parenthesis). Sandbox rejects that. Use 8gBm/:&EnhH.1/q — exported as ESEWA_UAT_SECRET_KEY. Verified: correct key → HTTP 302 to payment page; typo key → ES104.Khalti (KPG-2)#
JSON initiate → payment_url. Callback has no signature — lookup is mandatory.
Auth header#
Common mistake
UseAuthorization: Key <secret> — literal word Key, not Bearer. Wrong format surfaces as typed GatewayApiError.Lookup mapping#
| Gateway status | Type | Default | Description |
|---|---|---|---|
| Completed | → confirmed | — | Only status meaning paid. |
| Pending / Initiated | → pending | — | Hold; contact Khalti if stuck. |
| Expired / User canceled | → failed | — | Not paid. |
| Refunded | → refunded | — | Full refund. |
| Partially refunded | → partially_refunded | — | Partial refund. |
Scalability#
Designed so new gateways and stores are additive — no SDK fork required.
Custom gateways#
import { registerGateway, type PaymentGateway } from "@itzsa/nepal-pay";
registerGateway("fonepay", (ctx) => {
// Implement PaymentGateway against Fonepay docs
const gateway: PaymentGateway = {
name: "fonepay",
async initiate(req) { /* … */ },
async handleCallback(query) { /* never return confirmed */ },
async verify(ref) { /* only path to confirmed */ },
async refund() { /* … */ },
};
return gateway;
});
const fonepay = pay.gateway("fonepay");Prisma store#
import { PrismaPaymentStore } from "@itzsa/nepal-pay";
import { prisma } from "./db"; // your PrismaClient
const store = new PrismaPaymentStore(prisma);
// schema:
// model Payment {
// id String @id @default(cuid())
// gateway String
// providerRef String
// orderId String
// amount Float
// status String
// metadata Json?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// @@unique([gateway, providerRef])
// }HTTP timeouts & retries#
All gateway HTTP goes through fetchJson: default 15s timeout, one retry on network / 5xx / 429. Tune via timeoutMs / retries on createNepalPay. Under concurrent return-URL hits, MemoryPaymentStore serializes updates per payment id; Prisma uses a transaction + unique constraint.