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.

@itzsa/nepal-payheadlessNode 18+ESM + CJS

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-pay

Quick 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.redirectUrl

Test 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. 1

    Generate signed form

    Server HMAC-SHA256 over total_amount,transaction_uuid,product_code → Base64 signature.

  2. 2

    HTML form POST

    Browser POSTs hidden fields to https://rc-epay.esewa.com.np/api/epay/main/v2/form

  3. 3

    Pay on eSewa UAT

    Login 9711111111 / Nepal@123 / token 123456 — confirm payment.

  4. 4

    Redirect to success_url

    eSewa appends ?data=<base64 JSON> (signed callback — still untrusted alone).

  5. 5

    Verify signature + status API

    Recompute HMAC, then GET status — only COMPLETE means safe to fulfill.

Payment method

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 + status

Integration 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 secret

Flow comparison#

Arrow paths for both gateways.

eSewa

  1. 1

    Generate signed form

    Server HMAC-SHA256 over total_amount,transaction_uuid,product_code → Base64 signature.

  2. 2

    HTML form POST

    Browser POSTs hidden fields to https://rc-epay.esewa.com.np/api/epay/main/v2/form

  3. 3

    Pay on eSewa UAT

    Login 9711111111 / Nepal@123 / token 123456 — confirm payment.

  4. 4

    Redirect to success_url

    eSewa appends ?data=<base64 JSON> (signed callback — still untrusted alone).

  5. 5

    Verify signature + status API

    Recompute HMAC, then GET status — only COMPLETE means safe to fulfill.

Khalti

  1. 1

    Initiate (JSON)

    POST /epayment/initiate/ with amount in paisa (NPR × 100). Auth: Key <secret>.

  2. 2

    Redirect to payment_url

    Browser GET to test-pay.khalti.com/?pidx=… (expires ~30–60 min).

  3. 3

    Pay on Khalti sandbox

    Khalti ID 9800000000–9800000005 · MPIN 1111 · OTP 987654.

  4. 4

    Redirect to return_url

    Query params: pidx, status, amount, … — no signature; never trust alone.

  5. 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"
  }
}
StatusTypeDefaultDescription
COMPLETE→ confirmedOnly status meaning paid.
PENDING / AMBIGUOUS→ pendingHold; do not fulfill.
CANCELED / NOT_FOUND→ failedNot paid.
FULL_REFUND→ refundedFull refund.
PARTIAL_REFUND→ partially_refundedPartial 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."]
    }
  }
}
StatusTypeDefaultDescription
Completed→ confirmedOnly status meaning paid.
Pending / Initiated→ pendingHold; contact Khalti if stuck.
Expired / User canceled→ failedNot paid.
Refunded→ refundedFull refund.
Partially refunded→ partially_refundedPartial 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 }
}
ClassTypeDefaultDescription
ConfigErrorCONFIGMissing keys, invalid amount, bad constructor options.
SignatureMismatchErrorSIGNATURE_MISMATCHeSewa callback HMAC did not match — treat as tampering.
VerificationFailedErrorVERIFICATION_FAILEDverify() did not confirm payment.
GatewayApiErrorGATEWAY_APIUpstream HTTP / network / timeout. Includes statusCode + body.
InvalidTransitionErrorINVALID_TRANSITIONIllegal state machine move (e.g. callback → confirmed).
RefundNotSupportedErrorREFUND_NOT_SUPPORTEDrefund() 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 only

State machine#

TypeScript + runtime enforcement. handleCallback cannot produce confirmed.

StatusTypeDefaultDescription
pendinginitialCreated after initiate; awaiting user return.
callback_receivedfrom handleCallbackBrowser returned — still untrusted.
verifyingbefore verify()Server-side check in progress.
confirmedfrom verify() onlySafe to fulfill the order.
failedcancel / verify failDo not deliver.
refundedpost-confirmMapped from gateway refund statuses.
pending
callback_received
verifying
confirmed

After initiate() — user is on the gateway page

onConfirmed calls: 0Demo only — no live gateway calls
  • 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). Passing 10.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.

  1. Call service.start() — persists pending.
  2. Send the user to initiate.redirectUrl (GET for Khalti, auto-POST form for eSewa).
  3. On return, call service.handleReturn(query).
  4. 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 → failureUrl

Express#

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#

NameTypeDefaultDescription
mode'sandbox' | 'production'Selects gateway base URLs. No env-var magic inside the library.
esewa.productCodestringMerchant product code (UAT: EPAYTEST).
esewa.secretKeystringHMAC secret from eSewa. Consumer loads secrets; SDK does not.
khalti.secretKeystringLive/test secret. Sent as Authorization: Key <secret>.
timeoutMsnumber15000Abort gateway HTTP calls after this many ms.
retriesnumber1Extra attempts on network / 5xx / 429 (not on 4xx).

PaymentRequest

FieldTypeDefaultDescription
amountnumberNPR decimal at the public API (e.g. 10.50). Never paisa.
orderIdstringUnique merchant order / invoice id.
orderNamestringHuman label (required by Khalti).
returnUrlstringAbsolute success / return URL.
websiteUrlstringMerchant site URL (required by Khalti).
failureUrlstring?eSewa failure redirect; defaults to returnUrl.
taxAmount / serviceCharge / deliveryChargenumber?0eSewa breakdown; total must equal amount + these.
customer{ name?, email?, phone? }?Optional Khalti customer_info.
metadataRecord<string, string>?Opaque fields. merchant_* echoed by Khalti; transaction_uuid override for eSewa.

PaymentGateway#

MethodTypeDefaultDescription
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#

MethodTypeDefaultDescription
start(req) => Promise<{ record, initiate }>initiate() + store.create(pending) in one call.
handleReturn(query) => Promise<ReturnUrlHandlerResult>callback → verifying → verify → idempotent confirm + optional onConfirmed.

PaymentStore#

MethodTypeDefaultDescription
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#

ClassTypeDefaultDescription
ConfigErrorCONFIGMissing keys, invalid amount, bad constructor options.
SignatureMismatchErrorSIGNATURE_MISMATCHeSewa callback HMAC did not match — treat as tampering.
VerificationFailedErrorVERIFICATION_FAILEDverify() did not confirm payment.
GatewayApiErrorGATEWAY_APIUpstream HTTP / network / timeout. Includes statusCode + body.
InvalidTransitionErrorINVALID_TRANSITIONIllegal state machine move (e.g. callback → confirmed).
RefundNotSupportedErrorREFUND_NOT_SUPPORTEDrefund() 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 statusTypeDefaultDescription
COMPLETE→ confirmedOnly status meaning paid.
PENDING / AMBIGUOUS→ pendingHold; do not fulfill.
CANCELED / NOT_FOUND→ failedNot paid.
FULL_REFUND→ refundedFull refund.
PARTIAL_REFUND→ partially_refundedPartial refund.

Docs divergence#

ES104 — Invalid payload signature

Some eSewa doc pages print the UAT secret as 8gBm/:&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

Use Authorization: Key <secret> — literal word Key, not Bearer. Wrong format surfaces as typed GatewayApiError.

Lookup mapping#

Gateway statusTypeDefaultDescription
Completed→ confirmedOnly status meaning paid.
Pending / Initiated→ pendingHold; contact Khalti if stuck.
Expired / User canceled→ failedNot paid.
Refunded→ refundedFull refund.
Partially refunded→ partially_refundedPartial 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.