Documentation · itzsa

eSewa, Khalti & ConnectIPS — @itzsa/nepal-pay

Unified TypeScript SDK for eSewa (ePay v2), Khalti (KPG-2), and connectIPS (NCHL). 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), Khalti, or NCHL/connectIPS. Merchant agreements and credentials stay between you and the providers. eSewa docs · Khalti docs · connectIPS Process Interface Doc v5.1.

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!,
  },
  connectips: {
    merchantId: process.env.CONNECTIPS_MERCHANT_ID!,
    appId: process.env.CONNECTIPS_APP_ID!,
    appName: process.env.CONNECTIPS_APP_NAME!,
    password: process.env.CONNECTIPS_PASSWORD!,
    pfx: process.env.CONNECTIPS_PFX_BASE64!,
    pfxPassword: process.env.CONNECTIPS_PFX_PASSWORD!,
  },
});

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 eSewa, Khalti, and connectIPS — same fields, per-gateway arrow flow, full code always visible, then draft → pay → return → verify.

Complete a transaction

Pick eSewa, Khalti, or connectIPS, fill the form, generate/draft/initiate, then follow the pay or simulate button. Returns land on /nepal-pay/return, /nepal-pay/khalti-return, or /nepal-pay/connectips-return for server-side verify. connectIPS mock mode builds a real RSA-signed form payload, then simulates validatetxn SUCCESS.

Unified checkout#

Name, amount, reference, URLs, tax/service/delivery, gateway toggle — draft payment + payload for each rail.

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

    Draft the payment

    Your server takes the order total in NPR (rupees). Optional tax, service, and delivery are added into total_amount.

  2. 2

    Sign the form

    HMAC-SHA256 over three fields in a fixed order (total_amount, transaction_uuid, product_code), then Base64. That signature proves the form came from you.

  3. 3

    Browser form POST

    Customer’s browser POSTs hidden fields to eSewa’s form URL (sandbox: rc-epay.esewa.com.np). Same idea as a classic bank redirect.

  4. 4

    Customer pays

    They log in on eSewa and confirm. UAT demo: ID 9711111111 · password Nepal@123 · token 123456.

  5. 5

    Return to your site

    eSewa redirects to your success_url with ?data=… (Base64 JSON). Treat this as a hint only — not proof of payment.

  6. 6

    Verify on the server

    Re-check the HMAC, then call eSewa’s status API. Only status COMPLETE means you may deliver the order.

Payment method

Full code (always visible)

// Unified checkout → esewa
const payload = {
  amount: 100,
  taxAmount: 0,
  serviceCharge: 0,
  deliveryCharge: 0,
  orderId: "ORD-DEMO-001",
  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 | Khalti | connectIPS
// 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?data=…
// Khalti     → POST /api/nepal-pay/khalti/initiate
//              → payment_url → /nepal-pay/khalti-return?pidx=…
// connectIPS → POST /api/nepal-pay/connectips/initiate
//              → HTML form POST → /nepal-pay/connectips-return?TXNID=…
//
// eSewa UAT: 9711111111 / Nepal@123 / 123456 · secret 8gBm/:&EnhH.1/q
// Khalti:    9800000000–05 / MPIN 1111 / OTP 987654 · merchant test secret
// connectIPS: NCHL merchant + CREDITOR.pfx (docs mock drafts signed payload)

Flow diagrams#

Six phases each: draft → sign/initiate → pay → return → verify. Text stays inside the white cards.

eSewa (ePay v2)

Signed form · NPR · status API

  1. 1

    Draft the payment

    Your server takes the order total in NPR (rupees). Optional tax, service, and delivery are added into total_amount.

  2. 2

    Sign the form

    HMAC-SHA256 over three fields in a fixed order (total_amount, transaction_uuid, product_code), then Base64. That signature proves the form came from you.

  3. 3

    Browser form POST

    Customer’s browser POSTs hidden fields to eSewa’s form URL (sandbox: rc-epay.esewa.com.np). Same idea as a classic bank redirect.

  4. 4

    Customer pays

    They log in on eSewa and confirm. UAT demo: ID 9711111111 · password Nepal@123 · token 123456.

  5. 5

    Return to your site

    eSewa redirects to your success_url with ?data=… (Base64 JSON). Treat this as a hint only — not proof of payment.

  6. 6

    Verify on the server

    Re-check the HMAC, then call eSewa’s status API. Only status COMPLETE means you may deliver the order.

Khalti (KPG-2)

JSON initiate · paisa · lookup

  1. 1

    Draft the payment

    Your app uses NPR (e.g. 10.50). The SDK converts to paisa (×100 → 1050) before talking to Khalti.

  2. 2

    Initiate (JSON API)

    Server POSTs to /epayment/initiate/ with Authorization: Key <secret>. Khalti returns a payment_url and pidx.

  3. 3

    Redirect the customer

    Send the browser to payment_url (sandbox test-pay.khalti.com). The link expires in about 30–60 minutes.

  4. 4

    Customer pays

    They pay in the Khalti app/web UI. Sandbox: IDs 9800000000–05 · MPIN 1111 · OTP 987654.

  5. 5

    Return to your site

    Khalti redirects to return_url with pidx, status, amount, etc. There is no signature — never trust this alone.

  6. 6

    Lookup verify

    Server POSTs /epayment/lookup/ with the pidx. Only status Completed means you may deliver the order.

connectIPS (NCHL)

RSA TOKEN · paisa · validatetxn

  1. 1

    Draft the payment

    Use NPR in your app. Convert to paisa for TXNAMT (10.50 → 1050). Pick a unique TXNID (max 20 chars) and TXNDATE as DD-MM-YYYY.

  2. 2

    Build & sign TOKEN

    Join the login fields with commas (no spaces), end with TOKEN=TOKEN, then SHA256withRSA and Base64. That becomes the TOKEN form field.

  3. 3

    Browser form POST

    Customer’s browser POSTs the fields to /connectipswebgw/loginpage (UAT host: uat.connectips.com).

  4. 4

    Customer pays

    They finish payment in connectIPS / their bank. NCHL uses fixed success and failure URLs you registered earlier.

  5. 5

    Return with TXNID

    Browser lands on your success URL with only ?TXNID=…. No signature. Failure URL can use ?outcome=failure so you know it was a cancel.

  6. 6

    validatetxn verify

    Server POSTs validatetxn with Basic Auth (APPID + password) and a second RSA token. Only status SUCCESS means deliver.

Gateway comparison#

Same ideas side-by-side — what a layman cares about under each technical row.

AspecteSewaKhalticonnectIPS

How payment starts

How the customer is sent to the gateway

HTML form POSTJSON then open payment_urlHTML form POST

Amount on the wire

Unit sent to the gateway API

NPR (rupees)Paisa (NPR × 100)Paisa (NPR × 100)

Prove initiate is yours

Auth or signature when starting payment

HMAC-SHA256Auth header Key …RSA TOKEN

What comes back

Query params on your return URL

Base64 data=…pidx + status + …TXNID only

Return URL trusted?

Can you mark paid from the redirect?

No — re-check HMACNo signatureNo signature

Must call to confirm

Server API that proves money moved

Status → COMPLETELookup → Completedvalidatetxn → SUCCESS

Payment id (providerRef)

Id you store and pass to verify()

transaction_uuidpidxTXNID

Sandbox host

Where test traffic goes

rc-epay.esewa.com.npdev.khalti.comuat.connectips.com

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→ 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."]
    }
  }
}
StatusTypeDefaultDescription
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.

connectIPS payloads#

Draft form (RSA TOKEN), callback TXNID, validatetxn request/response.

Good — draft initiate (form + TOKEN message)

{
  "ok": true,
  "gateway": "connectips",
  "mode": "sandbox",
  "amountNpr": 10.5,
  "amountPaisa": 1050,
  "loginTokenMessage": "MERCHANTID=902,APPID=MER-902-APP-1,APPNAME=Demo,TXNID=ord42txn001,TXNDATE=22-07-2026,TXNCRNCY=NPR,TXNAMT=1050,REFERENCEID=ord42txn001,REMARKS=Pro plan,PARTICULARS=Pro plan,TOKEN=TOKEN",
  "initiate": {
    "redirectUrl": "https://uat.connectips.com/connectipswebgw/loginpage",
    "providerRef": "ord42txn001",
    "method": "POST",
    "formFields": {
      "MERCHANTID": "902",
      "APPID": "MER-902-APP-1",
      "APPNAME": "Demo",
      "TXNID": "ord42txn001",
      "TXNDATE": "22-07-2026",
      "TXNCRNCY": "NPR",
      "TXNAMT": "1050",
      "REFERENCEID": "ord42txn001",
      "REMARKS": "Pro plan",
      "PARTICULARS": "Pro plan",
      "TOKEN": "<SHA256withRSA Base64>"
    }
  }
}

Info — callback (?TXNID= only — untrusted)

{
  "TXNID": "ord42txn001"
}

Request — validatetxn body

{
  "merchantId": 902,
  "appId": "MER-902-APP-1",
  "referenceId": "ord42txn001",
  "txnAmt": 1050,
  "token": "<SHA256withRSA over MERCHANTID=…,APPID=…,REFERENCEID=…,TXNAMT=…>"
}

Good — validatetxn SUCCESS (deliver service)

{
  "merchantId": 902,
  "appId": "MER-902-APP-1",
  "referenceId": "ord42txn001",
  "txnAmt": "1050",
  "token": null,
  "status": "SUCCESS",
  "statusDesc": "Transaction successful."
}
StatusTypeDefaultDescription
SUCCESS→ confirmed-Only validatetxn status meaning paid.
ERROR→ pending-Txn not found / incomplete — re-poll; do not fulfill.
FAILED→ failed-Not paid.

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
ConfigErrorCONFIG-Missing keys, invalid amount, bad constructor options.
SignatureMismatchErrorSIGNATURE_MISMATCH-eSewa callback HMAC did not match — treat as tampering.
VerificationFailedErrorVERIFICATION_FAILED-verify() did not confirm payment.
GatewayApiErrorGATEWAY_API-Upstream HTTP / network / timeout. Includes statusCode + body.
InvalidTransitionErrorINVALID_TRANSITION-Illegal state machine move (e.g. callback → confirmed).
RefundNotSupportedErrorREFUND_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/ · connectips/
  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
pendinginitial-Created after initiate; awaiting user return.
callback_receivedfrom handleCallback-Browser returned — still untrusted.
verifyingbefore verify()-Server-side check in progress.
confirmedfrom verify() only-Safe to fulfill the order.
failedcancel / verify fail-Do not deliver.
refundedpost-confirm-Mapped 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. Paisa conversion for Khalti / connectIPS is internal.

100× bug

eSewa uses NPR decimals. Khalti and connectIPS use paisa (NPR × 100). Passing 10.50 to initiate sends 1050 paisa to those gateways — 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);

connectIPS form POST#

const connectips = pay.gateway("connectips");
const service = createPaymentService(connectips, store, {
  successUrl: "https://example.com/pay/success",
  failureUrl: "https://example.com/pay/failed",
});

const { initiate } = await service.start({
  amount: 10.5, // → 1050 paisa in TXNAMT
  orderId: "order-42",
  orderName: "Pro plan",
  returnUrl: "https://example.com/pay/connectips/return", // NCHL registers static URLs
  websiteUrl: "https://example.com",
});

// Same HTML form POST pattern as eSewa (loginpage)
const html = `<!doctype html><html><body>
<form id="cips" action="${initiate.redirectUrl}" method="POST">
${Object.entries(initiate.formFields!)
  .map(([k, v]) => `<input type="hidden" name="${k}" value="${v}" />`)
  .join("")}
</form>
<script>document.getElementById("cips").submit()</script>
</body></html>`;

// Return URL gets ?TXNID=… only — always call verify() / handleReturn
// Point NCHL failure URL at …/return?outcome=failure

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.productCodestring-Merchant product code (UAT: EPAYTEST).
esewa.secretKeystring-HMAC secret from eSewa. Consumer loads secrets; SDK does not.
khalti.secretKeystring-Live/test secret. Sent as Authorization: Key <secret>.
connectips.merchantIdnumber | string-NCHL merchant id (CREDITOR).
connectips.appIdstring-Application id — also Basic Auth username for validate APIs.
connectips.appNamestring-App display name (≤30 chars on login form).
connectips.passwordstring-App password for Basic Auth on validatetxn / gettxndetail.
connectips.pfx / pfxPasswordBuffer | string / string-PKCS#12 (CREDITOR.pfx) as Buffer or base64 + passphrase. Or use privateKeyPem.
connectips.privateKeyPemstring?-PEM RSA private key alternative to pfx (handy for tests).
connectips.baseUrlstring?-Override host (default UAT https://uat.connectips.com / prod https://login.connectips.com).
timeoutMsnumber15000Abort gateway HTTP calls after this many ms.
retriesnumber1Extra attempts on network / 5xx / 429 (not on 4xx).

PaymentRequest

FieldTypeDefaultDescription
amountnumber-NPR decimal at the public API (e.g. 10.50). Never paisa.
orderIdstring-Unique merchant order / invoice id.
orderNamestring-Human label (required by Khalti).
returnUrlstring-Absolute success / return URL.
websiteUrlstring-Merchant 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 for eSewa; txn_id / txn_date / remarks / particulars for connectIPS.

PaymentGateway#

MethodTypeDefaultDescription
initiate(req) => Promise<InitiateResult>-Start payment. Khalti → payment_url (GET). eSewa / connectIPS → 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. connectIPS: validatetxn.
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
ConfigErrorCONFIG-Missing keys, invalid amount, bad constructor options.
SignatureMismatchErrorSIGNATURE_MISMATCH-eSewa callback HMAC did not match — treat as tampering.
VerificationFailedErrorVERIFICATION_FAILED-verify() did not confirm payment.
GatewayApiErrorGATEWAY_API-Upstream HTTP / network / timeout. Includes statusCode + body.
InvalidTransitionErrorINVALID_TRANSITION-Illegal state machine move (e.g. callback → confirmed).
RefundNotSupportedErrorREFUND_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 statusTypeDefaultDescription
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 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→ 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.

connectIPS (NCHL)#

Bank / wallet payments via Nepal Clearing House. Same safety rule as the others: a browser redirect is never proof of payment.

In plain words#

For product owners and first-time integrators.

  1. You draft an order on your server (amount, order id, remarks).
  2. You seal it with a digital signature (RSA TOKEN) so NCHL knows the request is really from your merchant app.
  3. The customer pays on the connectIPS / bank page after an HTML form POST.
  4. They come back to your site with only a transaction id (TXNID) — anyone could fake that URL.
  5. You ask NCHL “did this payment succeed?” via validatetxn. Only SUCCESS means deliver the product.

Who needs what from NCHL

After onboarding you receive merchant id, app id, app name, app password, and a CREDITOR.pfx certificate. Register static success and failure URLs with NCHL (they cannot be changed per payment like eSewa’s success_url).

Each phase#

What happens, who does it, and what you store.

Phase 1 — Draft payment

Start from NPR in your checkout (example: Rs. 10.50). The SDK converts to paisa for TXNAMT (1050). Create a unique TXNID (≤ 20 characters) — this is your providerRef. Set TXNDATE to today as DD-MM-YYYY. Persist a pending row in your DB keyed by that TXNID before sending the customer away.

Phase 2 — Build the TOKEN

Concatenate the login fields in the documented order, separated by commas with no spaces, and end with TOKEN=TOKEN. Sign that string with your private key (SHA256withRSA) and Base64-encode the result. That value is the form’s TOKEN field. Prefer exporting PEM from the PFX once; the playground can draft a mock-signed payload without NCHL.

Phase 3 — HTML form POST (loginpage)

Same pattern as eSewa: render a hidden form and auto-submit to …/connectipswebgw/loginpage. Do not turn this into a GET. UAT host: https://uat.connectips.com.

Phase 4 — Customer pays

The user authenticates with connectIPS or their bank. You do not control this UI. On success NCHL redirects to your registered success URL; on cancel/fail, to the failure URL.

Phase 5 — Return URL (untrusted)

Success redirect appends only ?TXNID=…. Point the NCHL failure URL at the same handler with ?outcome=failure so handleCallback can mark cancel without waiting on validate. Never fulfill from this step alone.

Phase 6 — validatetxn (only path to “paid”)

Your server POSTs to …/connectipswebws/api/creditor/validatetxn with Basic Auth (APPID as username, app password) and a signed body ( merchantId, appId, referenceId = TXNID, txnAmt in paisa). Map SUCCESS → confirmed, FAILED → failed, ERROR → pending (not found / incomplete — re-poll, do not deliver). Optional: gettxndetail for richer fields after SUCCESS.

Diagram (same six phases)

  1. 1

    Draft the payment

    Use NPR in your app. Convert to paisa for TXNAMT (10.50 → 1050). Pick a unique TXNID (max 20 chars) and TXNDATE as DD-MM-YYYY.

  2. 2

    Build & sign TOKEN

    Join the login fields with commas (no spaces), end with TOKEN=TOKEN, then SHA256withRSA and Base64. That becomes the TOKEN form field.

  3. 3

    Browser form POST

    Customer’s browser POSTs the fields to /connectipswebgw/loginpage (UAT host: uat.connectips.com).

  4. 4

    Customer pays

    They finish payment in connectIPS / their bank. NCHL uses fixed success and failure URLs you registered earlier.

  5. 5

    Return with TXNID

    Browser lands on your success URL with only ?TXNID=…. No signature. Failure URL can use ?outcome=failure so you know it was a cancel.

  6. 6

    validatetxn verify

    Server POSTs validatetxn with Basic Auth (APPID + password) and a second RSA token. Only status SUCCESS means deliver.

TOKEN signing#

Build the comma-joined string (no spaces after commas) ending in TOKEN=TOKEN, then sign with SHA256withRSA using the NCHL-issued PKCS#12 private key and Base64-encode. Prefer privateKeyPem (convert CREDITOR.pfx once with OpenSSL). pfx + pfxPassword works when openssl is on PATH. Amounts on the wire are paisa integers.

MERCHANTID=902,APPID=MER-902-APP-1,APPNAME=Demo,TXNID=ord42txn001,TXNDATE=22-07-2026,TXNCRNCY=NPR,TXNAMT=1050,REFERENCEID=ord42txn001,REMARKS=Pro plan,PARTICULARS=Pro plan,TOKEN=TOKEN
// → SHA256withRSA(privateKey) → Base64 → form field TOKEN

Login form fields#

Draft payment payload posted to /connectipswebgw/loginpage.

NameTypeDefaultDescription
MERCHANTIDstring-NCHL merchant id (≤20).
APPIDstring-Application id (≤20). Also Basic Auth username.
APPNAMEstring-Display name on login page (≤30).
TXNIDstring-Unique txn id ≤20 — becomes providerRef / REFERENCEID default.
TXNDATEDD-MM-YYYY-Transaction date (Nepal calendar day as registered).
TXNCRNCY"NPR"-Always NPR for merchant payments.
TXNAMTinteger paisa-NPR × 100 (e.g. 10.50 → 1050). Never send NPR decimals here.
REFERENCEIDstring-Merchant reference ≤20 (often same as TXNID).
REMARKSstring-≤50 chars — order label.
PARTICULARSstring-≤100 chars — order detail.
TOKENBase64-SHA256withRSA over comma-joined fields ending TOKEN=TOKEN (no spaces after commas).

validatetxn#

Callback is untrusted

Success/failure URLs only receive ?TXNID=. Always call verify() (Basic Auth + signed body). Point the NCHL failure URL at the same handler with ?outcome=failure so cancel can be detected before validate.
Gateway statusTypeDefaultDescription
SUCCESS→ confirmed-Only validatetxn status meaning paid.
ERROR→ pending-Txn not found / incomplete — re-poll; do not fulfill.
FAILED→ failed-Not paid.

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.