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.
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-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!,
},
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.redirectUrlTest 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
Draft the payment
Your server takes the order total in NPR (rupees). Optional tax, service, and delivery are added into total_amount.
- 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
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
Customer pays
They log in on eSewa and confirm. UAT demo: ID 9711111111 · password Nepal@123 · token 123456.
- 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
Verify on the server
Re-check the HMAC, then call eSewa’s status API. Only status COMPLETE means you may deliver the order.
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 + statusIntegration 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
Draft the payment
Your server takes the order total in NPR (rupees). Optional tax, service, and delivery are added into total_amount.
- 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
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
Customer pays
They log in on eSewa and confirm. UAT demo: ID 9711111111 · password Nepal@123 · token 123456.
- 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
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
Draft the payment
Your app uses NPR (e.g. 10.50). The SDK converts to paisa (×100 → 1050) before talking to Khalti.
- 2
Initiate (JSON API)
Server POSTs to /epayment/initiate/ with Authorization: Key <secret>. Khalti returns a payment_url and pidx.
- 3
Redirect the customer
Send the browser to payment_url (sandbox test-pay.khalti.com). The link expires in about 30–60 minutes.
- 4
Customer pays
They pay in the Khalti app/web UI. Sandbox: IDs 9800000000–05 · MPIN 1111 · OTP 987654.
- 5
Return to your site
Khalti redirects to return_url with pidx, status, amount, etc. There is no signature — never trust this alone.
- 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
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
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
Browser form POST
Customer’s browser POSTs the fields to /connectipswebgw/loginpage (UAT host: uat.connectips.com).
- 4
Customer pays
They finish payment in connectIPS / their bank. NCHL uses fixed success and failure URLs you registered earlier.
- 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
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.
| Aspect | eSewa | Khalti | connectIPS |
|---|---|---|---|
How payment starts How the customer is sent to the gateway | HTML form POST | JSON then open payment_url | HTML 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-SHA256 | Auth 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 HMAC | No signature | No signature |
Must call to confirm Server API that proves money moved | Status → COMPLETE | Lookup → Completed | validatetxn → SUCCESS |
Payment id (providerRef) Id you store and pass to verify() | transaction_uuid | pidx | TXNID |
Sandbox host Where test traffic goes | rc-epay.esewa.com.np | dev.khalti.com | uat.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"
}
}| 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. |
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."
}| Status | Type | Default | Description |
|---|---|---|---|
| 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 }
}| 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/ · 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 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. Paisa conversion for Khalti / connectIPS is internal.
100× bug
eSewa uses NPR decimals. Khalti and connectIPS use paisa (NPR × 100). Passing10.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.
- 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);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=failureReturn 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>. |
| connectips.merchantId | number | string | - | NCHL merchant id (CREDITOR). |
| connectips.appId | string | - | Application id — also Basic Auth username for validate APIs. |
| connectips.appName | string | - | App display name (≤30 chars on login form). |
| connectips.password | string | - | App password for Basic Auth on validatetxn / gettxndetail. |
| connectips.pfx / pfxPassword | Buffer | string / string | - | PKCS#12 (CREDITOR.pfx) as Buffer or base64 + passphrase. Or use privateKeyPem. |
| connectips.privateKeyPem | string? | - | PEM RSA private key alternative to pfx (handy for tests). |
| connectips.baseUrl | string? | - | Override host (default UAT https://uat.connectips.com / prod https://login.connectips.com). |
| 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 for eSewa; txn_id / txn_date / remarks / particulars for connectIPS. |
PaymentGateway#
| Method | Type | Default | Description |
|---|---|---|---|
| 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#
| 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. |
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.
- You draft an order on your server (amount, order id, remarks).
- You seal it with a digital signature (RSA TOKEN) so NCHL knows the request is really from your merchant app.
- The customer pays on the connectIPS / bank page after an HTML form POST.
- They come back to your site with only a transaction id (
TXNID) — anyone could fake that URL. - You ask NCHL “did this payment succeed?” via
validatetxn. OnlySUCCESSmeans deliver the product.
Who needs what from NCHL
After onboarding you receive merchant id, app id, app name, app password, and aCREDITOR.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
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
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
Browser form POST
Customer’s browser POSTs the fields to /connectipswebgw/loginpage (UAT host: uat.connectips.com).
- 4
Customer pays
They finish payment in connectIPS / their bank. NCHL uses fixed success and failure URLs you registered earlier.
- 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
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 TOKENLogin form fields#
Draft payment payload posted to /connectipswebgw/loginpage.
| Name | Type | Default | Description |
|---|---|---|---|
| MERCHANTID | string | - | NCHL merchant id (≤20). |
| APPID | string | - | Application id (≤20). Also Basic Auth username. |
| APPNAME | string | - | Display name on login page (≤30). |
| TXNID | string | - | Unique txn id ≤20 — becomes providerRef / REFERENCEID default. |
| TXNDATE | DD-MM-YYYY | - | Transaction date (Nepal calendar day as registered). |
| TXNCRNCY | "NPR" | - | Always NPR for merchant payments. |
| TXNAMT | integer paisa | - | NPR × 100 (e.g. 10.50 → 1050). Never send NPR decimals here. |
| REFERENCEID | string | - | Merchant reference ≤20 (often same as TXNID). |
| REMARKS | string | - | ≤50 chars — order label. |
| PARTICULARS | string | - | ≤100 chars — order detail. |
| TOKEN | Base64 | - | 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 status | Type | Default | Description |
|---|---|---|---|
| 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.