Back to Blog
Node.js

How to Integrate eSewa, Khalti, and Fonepay Payment Gateways in Node.js

A complete TypeScript guide to accepting payments in Nepal with eSewa, Khalti, and Fonepay—signatures, redirects, server-side verification, and the mistakes that cause lost or double-counted payments.

I am Sajan Acharya, a Lead Software Engineer in Kathmandu. Almost every product built for Nepali users eventually needs the same three buttons at checkout: Pay with eSewa, Pay with Khalti, and Pay with Fonepay. Each gateway has its own flow, its own signature scheme, and its own quirks, and most tutorials stop at “redirect the user and trust the success page.” That approach loses money. In this guide I show how I integrate all three in Node.js with TypeScript for production systems: HMAC signatures, server-side verification, amounts that cannot be tampered with, and order updates that stay correct even when callbacks arrive twice.

The code follows the clean architecture layout from how to structure a Node.js backend with clean architecture: every gateway implements one IPaymentGateway port, so your checkout use cases never care which wallet the customer chose. It also builds on two earlier guides—idempotency keys in payment gateways and payment reconciliation—which cover the safety nets every serious payment system needs.

How eSewa, Khalti, and Fonepay flows differ

All three gateways follow the same high-level pattern: your server creates a payment, the customer approves it on the gateway’s page, the gateway redirects back to you, and your server verifies the result directly with the gateway before marking the order paid. The differences are in the details. eSewa ePay v2 expects an HTML form POST with an HMAC-SHA256 signature. Khalti’s ePayment API is a JSON call that returns a payment URL. Fonepay uses a signed redirect URL (or dynamic QR) with HMAC-SHA512.

Browser                Node.js API                         Gateway
   | POST /payments/:gateway/initiate {orderId}                |
   |---------------------->| load order, amount from DB        |
   |                       | create Payment (PENDING)          |
   |                       | sign request / call initiate API  |
   |<---- form fields (eSewa) or payment_url (Khalti/Fonepay) -|
   |------------------------------------------------------------>| customer pays
   |<------------ redirect to /payments/:gateway/callback ------|
   |---------------------->| verify signature                  |
   |                       | call status / lookup API -------->|
   |                       |<------------- COMPLETE -----------|
   |                       | amount matches? mark PAID (once)  |
   |<-- redirect to /orders/:id?payment=completed              |

eSewa ePay v2  → HTML form POST, HMAC-SHA256 (base64), status check API
Khalti         → JSON initiate → payment_url, lookup API by pidx (amounts in paisa)
Fonepay        → signed redirect URL or dynamic QR, HMAC-SHA512, verification API

The golden rule: the redirect back to your site is only a hint. Anyone can open your callback URL with hand-crafted query parameters. A payment is real only after your server confirms it with the gateway’s own API and the amount matches the order in your database.

Sandbox credentials and environment setup

Both eSewa and Khalti provide public sandbox environments, so you can build the full flow before signing a merchant agreement. Fonepay shares its sandbox and integration document after merchant onboarding with your bank or Fonepay directly. The values below are the publicly documented test values at the time of writing; always confirm against the latest developer docs before going live.

# .env (sandbox)
API_URL=http://localhost:4000
FRONTEND_URL=http://localhost:3000

# eSewa ePay v2 (test)
ESEWA_PRODUCT_CODE=EPAYTEST
ESEWA_SECRET_KEY=8gBm/:&EnhH.1/q
ESEWA_FORM_URL=https://rc-epay.esewa.com.np/api/epay/main/v2/form
ESEWA_STATUS_URL=https://rc.esewa.com.np/api/epay/transaction/status/

# Khalti ePayment (sandbox) — secret key from the test merchant dashboard
KHALTI_BASE_URL=https://dev.khalti.com/api/v2
KHALTI_SECRET_KEY=your-khalti-sandbox-secret-key

# Fonepay — values provided by Fonepay after onboarding
FONEPAY_MERCHANT_CODE=your-merchant-code
FONEPAY_SECRET_KEY=your-secret-key
FONEPAY_BASE_URL=https://dev-clientapi.fonepay.com

# Production endpoints (swap when you go live)
# eSewa form:   https://epay.esewa.com.np/api/epay/main/v2/form
# eSewa status: https://epay.esewa.com.np/api/epay/transaction/status/
# Khalti:       https://khalti.com/api/v2

# Sandbox test wallets (from the gateways' public docs)
# eSewa:  ID 9806800001 (to 9806800005), password Nepal@123, MPIN 1122, token 123456
# Khalti: ID 9800000000 (to 9800000005), MPIN 1111, OTP 987654

Domain and ports: one contract for every gateway

Money is stored in minor units (paisa) everywhere inside the system. Floating-point rupees are how you end up with NPR 999.9999 in a ledger. The Payment entity records which gateway was used, the amount we expect, the status, and the gateway’s own reference so callbacks can be matched to the right row. Each gateway adapter translates between this model and the gateway’s format.

// src/domain/entities/Payment.ts
export type PaymentGatewayName = "esewa" | "khalti" | "fonepay";
export type PaymentStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED";

export type Payment = {
  id: string;
  orderId: string;
  gateway: PaymentGatewayName;
  amountMinor: number;
  status: PaymentStatus;
  gatewayRef?: string;
  gatewayTransactionId?: string;
};

// src/domain/repositories/IPaymentRepository.ts
import { Payment, PaymentGatewayName } from "../entities/Payment";

export interface IPaymentRepository {
  create(data: { orderId: string; gateway: PaymentGatewayName; amountMinor: number }): Promise<Payment>;
  attachGatewayRef(id: string, gatewayRef: string): Promise<void>;
  findByGatewayRef(gateway: PaymentGatewayName, gatewayRef: string): Promise<Payment | null>;
  completeIfPending(id: string, gatewayTransactionId?: string): Promise<boolean>;
  markFailed(id: string): Promise<void>;
}

// src/domain/repositories/IOrderRepository.ts
export type PayableOrder = { id: string; title: string; totalMinor: number };

export interface IOrderRepository {
  findPayableOrder(orderId: string, userId: string): Promise<PayableOrder | null>;
  markPaid(orderId: string): Promise<void>;
}
// src/application/errors/PaymentError.ts
export class PaymentError extends Error {
  constructor(
    message: string,
    readonly statusCode = 400
  ) {
    super(message);
    this.name = "PaymentError";
  }
}

// src/application/ports/IPaymentGateway.ts
import { PaymentGatewayName, PaymentStatus } from "../../domain/entities/Payment";

export type InitiatePaymentInput = {
  paymentId: string;
  orderId: string;
  amountMinor: number;
  productName: string;
  customer: { name: string; email: string; phone: string };
};

export type InitiatePaymentResult =
  | { kind: "redirect"; url: string; gatewayRef: string }
  | { kind: "form"; action: string; fields: Record<string, string>; gatewayRef: string };

export type VerifiedPayment = {
  gatewayRef: string;
  status: PaymentStatus;
  amountMinor: number;
  gatewayTransactionId?: string;
};

export interface IPaymentGateway {
  readonly name: PaymentGatewayName;
  initiate(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
  verify(callbackQuery: Record<string, string>): Promise<VerifiedPayment>;
}

export type PaymentGatewayRegistry = Record<PaymentGatewayName, IPaymentGateway>;

InitiatePaymentResult has two shapes because the gateways do. eSewa needs the browser to submit a form with signed fields; Khalti and Fonepay give you a URL to redirect to. The frontend handles both with a few lines of code, shown later.

eSewa ePay v2 integration with HMAC-SHA256

eSewa ePay v2 signs the fields listed in signed_field_names—by default total_amount, transaction_uuid, and product_code—joined as key=value pairs separated by commas. The signature is an HMAC-SHA256 of that string with your secret key, encoded in base64. On success, eSewa redirects to your success_url with a data query parameter: a base64-encoded JSON object that includes its own signature. We verify that signature, then call eSewa’s status API as a second, independent confirmation.

// src/infrastructure/payments/EsewaGateway.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { PaymentError } from "../../application/errors/PaymentError";
import {
  IPaymentGateway,
  InitiatePaymentInput,
  InitiatePaymentResult,
  VerifiedPayment,
} from "../../application/ports/IPaymentGateway";
import { PaymentStatus } from "../../domain/entities/Payment";

type EsewaConfig = {
  productCode: string;
  secretKey: string;
  formUrl: string;
  statusUrl: string;
  successUrl: string;
  failureUrl: string;
};

type EsewaCallbackPayload = Record<string, string> & {
  transaction_code: string;
  status: string;
  total_amount: string;
  transaction_uuid: string;
  signed_field_names: string;
  signature: string;
};

const ESEWA_STATUS: Record<string, PaymentStatus> = {
  COMPLETE: "COMPLETED",
  PENDING: "PENDING",
  AMBIGUOUS: "PENDING",
  FULL_REFUND: "REFUNDED",
  PARTIAL_REFUND: "REFUNDED",
};

function toRupees(amountMinor: number): string {
  return (amountMinor / 100).toFixed(2);
}

function toMinor(rupees: string): number {
  return Math.round(Number(rupees.replaceAll(",", "")) * 100);
}

function safeEqual(a: string, b: string): boolean {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}

export class EsewaGateway implements IPaymentGateway {
  readonly name = "esewa" as const;

  constructor(private readonly config: EsewaConfig) {}

  async initiate(input: InitiatePaymentInput): Promise<InitiatePaymentResult> {
    const totalAmount = toRupees(input.amountMinor);
    const fields: Record<string, string> = {
      amount: totalAmount,
      tax_amount: "0",
      product_service_charge: "0",
      product_delivery_charge: "0",
      total_amount: totalAmount,
      transaction_uuid: input.paymentId,
      product_code: this.config.productCode,
      success_url: this.config.successUrl,
      failure_url: this.config.failureUrl,
      signed_field_names: "total_amount,transaction_uuid,product_code",
    };
    fields.signature = this.sign(fields, fields.signed_field_names);

    return { kind: "form", action: this.config.formUrl, fields, gatewayRef: input.paymentId };
  }

  async verify(query: Record<string, string>): Promise<VerifiedPayment> {
    if (!query.data) throw new PaymentError("Missing eSewa response data");

    const payload = JSON.parse(
      Buffer.from(query.data, "base64").toString("utf8")
    ) as EsewaCallbackPayload;

    const expected = this.sign(payload, payload.signed_field_names);
    if (!safeEqual(expected, payload.signature)) {
      throw new PaymentError("Invalid eSewa signature");
    }

    const status = await this.checkStatus(payload.transaction_uuid, payload.total_amount);
    return {
      gatewayRef: payload.transaction_uuid,
      status,
      amountMinor: toMinor(payload.total_amount),
      gatewayTransactionId: payload.transaction_code,
    };
  }

  private async checkStatus(transactionUuid: string, totalAmount: string): Promise<PaymentStatus> {
    const url = new URL(this.config.statusUrl);
    url.search = new URLSearchParams({
      product_code: this.config.productCode,
      total_amount: totalAmount.replaceAll(",", ""),
      transaction_uuid: transactionUuid,
    }).toString();

    const response = await fetch(url);
    if (!response.ok) throw new PaymentError("eSewa status check failed: " + response.status, 502);

    const body = (await response.json()) as { status: string };
    return ESEWA_STATUS[body.status] ?? "FAILED";
  }

  private sign(values: Record<string, string>, signedFieldNames: string): string {
    const message = signedFieldNames
      .split(",")
      .map((field) => field + "=" + values[field])
      .join(",");
    return createHmac("sha256", this.config.secretKey).update(message).digest("base64");
  }
}
  • transaction_uuid must be unique per payment attempt, which is why we use our Payment ID rather than the order ID—customers retry, and eSewa rejects reused IDs
  • total_amount must equal amount + tax_amount + product_service_charge + product_delivery_charge, and the signed string must use exactly the values you submit
  • eSewa can return total_amount formatted with commas (for example 1,000.0); verify the signature with the raw value but normalize it before comparing amounts
  • Compare signatures with timingSafeEqual instead of === to avoid leaking information through timing
  • Always call the status API; a valid signature proves the message came from eSewa, the status API proves the payment is still complete right now

Khalti ePayment integration

Khalti’s ePayment API is the simplest of the three. Your server calls the initiate endpoint with the amount in paisa, an order ID, and a return URL, authenticated with an Authorization: Key header containing your secret key. Khalti responds with a pidx (payment identifier) and a payment_url. After the customer pays or cancels, Khalti redirects to your return_url with the pidx and a status in the query string—but you must confirm it with the lookup API, which is the only trustworthy source of truth.

// src/infrastructure/payments/KhaltiGateway.ts
import { PaymentError } from "../../application/errors/PaymentError";
import {
  IPaymentGateway,
  InitiatePaymentInput,
  InitiatePaymentResult,
  VerifiedPayment,
} from "../../application/ports/IPaymentGateway";
import { PaymentStatus } from "../../domain/entities/Payment";

type KhaltiConfig = {
  secretKey: string;
  baseUrl: string;
  returnUrl: string;
  websiteUrl: string;
};

type KhaltiLookupResponse = {
  pidx: string;
  status: string;
  total_amount: number;
  transaction_id: string | null;
};

const KHALTI_STATUS: Record<string, PaymentStatus> = {
  Completed: "COMPLETED",
  Pending: "PENDING",
  Initiated: "PENDING",
  Refunded: "REFUNDED",
  "Partially Refunded": "REFUNDED",
};

export class KhaltiGateway implements IPaymentGateway {
  readonly name = "khalti" as const;

  constructor(private readonly config: KhaltiConfig) {}

  async initiate(input: InitiatePaymentInput): Promise<InitiatePaymentResult> {
    const body = await this.post<{ pidx: string; payment_url: string }>("/epayment/initiate/", {
      return_url: this.config.returnUrl,
      website_url: this.config.websiteUrl,
      amount: input.amountMinor,
      purchase_order_id: input.paymentId,
      purchase_order_name: input.productName,
      customer_info: input.customer,
    });

    return { kind: "redirect", url: body.payment_url, gatewayRef: body.pidx };
  }

  async verify(query: Record<string, string>): Promise<VerifiedPayment> {
    if (!query.pidx) throw new PaymentError("Missing Khalti pidx");

    const body = await this.post<KhaltiLookupResponse>("/epayment/lookup/", { pidx: query.pidx });
    return {
      gatewayRef: body.pidx,
      status: KHALTI_STATUS[body.status] ?? "FAILED",
      amountMinor: body.total_amount,
      gatewayTransactionId: body.transaction_id ?? undefined,
    };
  }

  private async post<T>(path: string, payload: unknown): Promise<T> {
    const response = await fetch(this.config.baseUrl + path, {
      method: "POST",
      headers: {
        Authorization: "Key " + this.config.secretKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      throw new PaymentError("Khalti request failed: " + response.status + " " + (await response.text()), 502);
    }
    return (await response.json()) as T;
  }
}
  • Khalti amounts are always in paisa: NPR 100 is 10000; the minimum payable amount is NPR 10 (1000 paisa)
  • Store the pidx as gatewayRef immediately after initiate so the callback can be matched even if the customer closes the tab and returns later
  • A pidx expires after a limited time; create a new Payment and pidx when the customer retries instead of reusing an expired one
  • Statuses like Expired and User canceled map to FAILED; Pending and Initiated stay PENDING and should be rechecked by a reconciliation job
  • Keep the secret key on the server only; the public key is only for Khalti’s older client-side widget

Fonepay integration: signed redirect and verification

Fonepay is the network behind bank QR payments across Nepal, so supporting it lets customers pay from almost any mobile banking app. Unlike eSewa and Khalti, the full integration document and sandbox are shared after merchant onboarding, and merchants choose between a web redirect flow and dynamic QR generation. Both use the same idea: you build a request whose fields are joined in a fixed order and signed with HMAC-SHA512 using your secret key, and you verify the response with a second signed call to Fonepay’s verification endpoint. The adapter below follows the commonly used web redirect fields; confirm the exact field names and order against the specification Fonepay gives you.

// src/infrastructure/payments/FonepayGateway.ts (web redirect flow — confirm fields with your Fonepay spec)
import { createHmac } from "node:crypto";
import {
  IPaymentGateway,
  InitiatePaymentInput,
  InitiatePaymentResult,
  VerifiedPayment,
} from "../../application/ports/IPaymentGateway";
import { PaymentError } from "../../application/errors/PaymentError";

type FonepayConfig = { merchantCode: string; secretKey: string; baseUrl: string; returnUrl: string };

function fonepaySign(values: string[], secretKey: string): string {
  return createHmac("sha512", secretKey).update(values.join(",")).digest("hex");
}

function fonepayDate(date: Date): string {
  return date.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" });
}

export class FonepayGateway implements IPaymentGateway {
  readonly name = "fonepay" as const;

  constructor(private readonly config: FonepayConfig) {}

  async initiate(input: InitiatePaymentInput): Promise<InitiatePaymentResult> {
    const params = {
      PID: this.config.merchantCode,
      MD: "P",
      PRN: input.paymentId,
      AMT: (input.amountMinor / 100).toFixed(2),
      CRN: "NPR",
      DT: fonepayDate(new Date()),
      R1: "Order " + input.orderId,
      R2: "N/A",
      RU: this.config.returnUrl,
    };
    const DV = fonepaySign(
      [params.PID, params.MD, params.PRN, params.AMT, params.CRN, params.DT, params.R1, params.R2, params.RU],
      this.config.secretKey
    );

    const url = new URL(this.config.baseUrl + "/api/merchantRequest");
    url.search = new URLSearchParams({ ...params, DV }).toString();
    return { kind: "redirect", url: url.toString(), gatewayRef: input.paymentId };
  }

  async verify(query: Record<string, string>): Promise<VerifiedPayment> {
    if (query.PS !== "true") {
      return { gatewayRef: query.PRN ?? "", status: "FAILED", amountMinor: 0 };
    }

    const amount = query.P_AMT;
    const DV = fonepaySign([this.config.merchantCode, amount, query.PRN, query.BID, query.UID], this.config.secretKey);
    const url = new URL(this.config.baseUrl + "/api/merchantRequest/verificationMerchant");
    url.search = new URLSearchParams({
      PRN: query.PRN,
      PID: this.config.merchantCode,
      BID: query.BID,
      AMT: amount,
      UID: query.UID,
      DV,
    }).toString();

    const response = await fetch(url);
    if (!response.ok) throw new PaymentError("Fonepay verification failed: " + response.status, 502);
    const body = await response.text();

    return {
      gatewayRef: query.PRN,
      status: body.includes("<success>true</success>") ? "COMPLETED" : "FAILED",
      amountMinor: Math.round(Number(amount) * 100),
      gatewayTransactionId: query.UID,
    };
  }
}

If you choose Fonepay’s dynamic QR flow instead, the same port still works: initiate returns the QR payload (or a page that renders it), and verify polls or receives Fonepay’s notification and checks it with the signed verification call. That is the benefit of designing around a port—your checkout use cases do not change when a merchant switches from redirect to QR.

Use cases: initiate and verify payments safely

InitiatePaymentUseCase never accepts an amount from the client. It loads the order for the logged-in user, creates a PENDING Payment with the order’s total, asks the gateway to initiate, and stores the gateway reference. VerifyPaymentUseCase is where money mistakes are prevented: it verifies with the gateway, finds the Payment by gateway reference, treats an already-completed payment as success without doing anything twice, rejects amount mismatches, and flips PENDING to COMPLETED with a conditional update so only one callback can win.

// src/application/use-cases/payments/InitiatePaymentUseCase.ts
import { PaymentGatewayName } from "../../../domain/entities/Payment";
import { IOrderRepository } from "../../../domain/repositories/IOrderRepository";
import { IPaymentRepository } from "../../../domain/repositories/IPaymentRepository";
import { PaymentError } from "../../errors/PaymentError";
import { InitiatePaymentResult, PaymentGatewayRegistry } from "../../ports/IPaymentGateway";

type InitiateInput = {
  orderId: string;
  userId: string;
  gateway: PaymentGatewayName;
  customer: { name: string; email: string; phone: string };
};

export class InitiatePaymentUseCase {
  constructor(
    private readonly gateways: PaymentGatewayRegistry,
    private readonly orders: IOrderRepository,
    private readonly payments: IPaymentRepository
  ) {}

  async execute(input: InitiateInput): Promise<InitiatePaymentResult> {
    const order = await this.orders.findPayableOrder(input.orderId, input.userId);
    if (!order) throw new PaymentError("Order not found or already paid", 404);

    const payment = await this.payments.create({
      orderId: order.id,
      gateway: input.gateway,
      amountMinor: order.totalMinor,
    });

    const result = await this.gateways[input.gateway].initiate({
      paymentId: payment.id,
      orderId: order.id,
      amountMinor: order.totalMinor,
      productName: order.title,
      customer: input.customer,
    });

    await this.payments.attachGatewayRef(payment.id, result.gatewayRef);
    return result;
  }
}

// src/application/use-cases/payments/VerifyPaymentUseCase.ts
import { PaymentGatewayName, PaymentStatus } from "../../../domain/entities/Payment";
import { IOrderRepository } from "../../../domain/repositories/IOrderRepository";
import { IPaymentRepository } from "../../../domain/repositories/IPaymentRepository";
import { PaymentError } from "../../errors/PaymentError";
import { PaymentGatewayRegistry } from "../../ports/IPaymentGateway";

export class VerifyPaymentUseCase {
  constructor(
    private readonly gateways: PaymentGatewayRegistry,
    private readonly orders: IOrderRepository,
    private readonly payments: IPaymentRepository
  ) {}

  async execute(
    gateway: PaymentGatewayName,
    query: Record<string, string>
  ): Promise<{ orderId: string; status: PaymentStatus }> {
    const verified = await this.gateways[gateway].verify(query);
    const payment = await this.payments.findByGatewayRef(gateway, verified.gatewayRef);
    if (!payment) throw new PaymentError("Unknown payment reference", 404);

    if (payment.status === "COMPLETED") {
      return { orderId: payment.orderId, status: "COMPLETED" };
    }

    if (verified.status === "COMPLETED") {
      if (verified.amountMinor !== payment.amountMinor) {
        throw new PaymentError("Amount mismatch for payment " + payment.id + "; flagged for review", 409);
      }
      const updated = await this.payments.completeIfPending(payment.id, verified.gatewayTransactionId);
      if (updated) await this.orders.markPaid(payment.orderId);
      return { orderId: payment.orderId, status: "COMPLETED" };
    }

    if (verified.status === "FAILED") await this.payments.markFailed(payment.id);
    return { orderId: payment.orderId, status: verified.status };
  }
}

completeIfPending is the idempotency guard. It updates the row only if its status is still PENDING and returns whether it changed anything. If the customer refreshes the success page, or eSewa and your reconciliation job confirm the same payment at the same moment, only the first call marks the order paid. In a system where marking paid also reserves stock or issues a ticket, wrap completeIfPending and markPaid in a single Unit of Work transaction so they succeed or fail together.

// src/infrastructure/mongoose/models/PaymentModel.ts
import { Schema, model } from "mongoose";

const paymentSchema = new Schema(
  {
    orderId: { type: Schema.Types.ObjectId, ref: "Order", required: true, index: true },
    gateway: { type: String, enum: ["esewa", "khalti", "fonepay"], required: true },
    amountMinor: { type: Number, required: true },
    status: { type: String, enum: ["PENDING", "COMPLETED", "FAILED", "REFUNDED"], default: "PENDING" },
    gatewayRef: { type: String },
    gatewayTransactionId: { type: String },
  },
  { timestamps: true }
);

paymentSchema.index({ gateway: 1, gatewayRef: 1 }, { unique: true, sparse: true });

export const PaymentModel = model("Payment", paymentSchema);

// src/infrastructure/repositories/PaymentRepository.ts (excerpt)
async completeIfPending(id: string, gatewayTransactionId?: string): Promise<boolean> {
  const result = await PaymentModel.updateOne(
    { _id: id, status: "PENDING" },
    { status: "COMPLETED", gatewayTransactionId }
  );
  return result.modifiedCount === 1;
}

Routes, callbacks, and factory wiring

Each gateway gets the same two routes: an authenticated POST to initiate and a public GET callback that verifies and redirects the customer to the frontend. eSewa also needs a failure URL; it carries no payment data, so it simply sends the customer back to checkout. Errors on the callback are logged and turned into a friendly redirect instead of a JSON error page, because a real person is looking at that browser tab.

// src/presentation/routes/paymentRoutes.ts
import { Request, RequestHandler, Router } from "express";
import { PaymentError } from "../../application/errors/PaymentError";
import { PaymentGatewayName } from "../../domain/entities/Payment";
import { env } from "../../config/env";
import { PaymentFactory } from "../factories/PaymentFactory";

const GATEWAYS: readonly string[] = ["esewa", "khalti", "fonepay"];

function parseGateway(value: string): PaymentGatewayName {
  if (!GATEWAYS.includes(value)) throw new PaymentError("Unsupported payment gateway", 400);
  return value as PaymentGatewayName;
}

function queryToRecord(req: Request): Record<string, string> {
  return Object.fromEntries(Object.entries(req.query).map(([key, value]) => [key, String(value)]));
}

export function paymentRoutes(factory: PaymentFactory, requireAuth: RequestHandler): Router {
  const router = Router();

  router.post("/:gateway/initiate", requireAuth, async (req, res) => {
    const result = await factory.initiatePayment.execute({
      orderId: String(req.body.orderId),
      userId: req.auth!.subject,
      gateway: parseGateway(req.params.gateway),
      customer: { name: req.auth!.name, email: req.auth!.email, phone: String(req.body.phone ?? "") },
    });
    res.json(result);
  });

  router.get("/:gateway/callback", async (req, res) => {
    try {
      const { orderId, status } = await factory.verifyPayment.execute(
        parseGateway(req.params.gateway),
        queryToRecord(req)
      );
      res.redirect(env.frontendUrl + "/orders/" + orderId + "?payment=" + status.toLowerCase());
    } catch (error) {
      console.error("Payment callback failed", error);
      res.redirect(env.frontendUrl + "/checkout?payment=error");
    }
  });

  router.get("/esewa/failure", (_req, res) => {
    res.redirect(env.frontendUrl + "/checkout?payment=failed");
  });

  return router;
}

// src/presentation/factories/PaymentFactory.ts (excerpt)
const gateways: PaymentGatewayRegistry = {
  esewa: new EsewaGateway({
    productCode: env.esewa.productCode,
    secretKey: env.esewa.secretKey,
    formUrl: env.esewa.formUrl,
    statusUrl: env.esewa.statusUrl,
    successUrl: env.apiUrl + "/payments/esewa/callback",
    failureUrl: env.apiUrl + "/payments/esewa/failure",
  }),
  khalti: new KhaltiGateway({
    secretKey: env.khalti.secretKey,
    baseUrl: env.khalti.baseUrl,
    returnUrl: env.apiUrl + "/payments/khalti/callback",
    websiteUrl: env.frontendUrl,
  }),
  fonepay: new FonepayGateway({
    merchantCode: env.fonepay.merchantCode,
    secretKey: env.fonepay.secretKey,
    baseUrl: env.fonepay.baseUrl,
    returnUrl: env.apiUrl + "/payments/fonepay/callback",
  }),
};

Frontend: handling form and redirect payments in React

The frontend never signs anything and never sends an amount. It asks the API to initiate a payment for an order and then either redirects to the returned URL (Khalti, Fonepay) or builds and submits a hidden form (eSewa). The same function powers all three buttons.

// PayButtons.tsx
type InitiateResult =
  | { kind: "redirect"; url: string }
  | { kind: "form"; action: string; fields: Record<string, string> };

async function startPayment(orderId: string, gateway: "esewa" | "khalti" | "fonepay") {
  const response = await fetch("/api/payments/" + gateway + "/initiate", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ orderId }),
  });
  if (!response.ok) throw new Error("Could not start payment");

  const result = (await response.json()) as InitiateResult;
  if (result.kind === "redirect") {
    window.location.href = result.url;
    return;
  }

  const form = document.createElement("form");
  form.method = "POST";
  form.action = result.action;
  for (const [name, value] of Object.entries(result.fields)) {
    const input = document.createElement("input");
    input.type = "hidden";
    input.name = name;
    input.value = value;
    form.appendChild(input);
  }
  document.body.appendChild(form);
  form.submit();
}

export function PayButtons({ orderId }: { orderId: string }) {
  return (
    <div className="flex gap-3">
      <button onClick={() => startPayment(orderId, "esewa")}>Pay with eSewa</button>
      <button onClick={() => startPayment(orderId, "khalti")}>Pay with Khalti</button>
      <button onClick={() => startPayment(orderId, "fonepay")}>Pay with Fonepay</button>
    </div>
  );
}

Testing the integration end to end

Test the unhappy paths as seriously as the happy one. The bugs that cost real money are replayed callbacks, tampered query strings, and payments that stay pending because the customer closed the tab.

# 1. Happy path: create an order of NPR 1,000 and pay with each sandbox wallet
$ curl -s -X POST http://localhost:4000/payments/khalti/initiate -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"orderId":"66fa..."}'
{"kind":"redirect","url":"https://test-pay.khalti.com/?pidx=bZQLD9wRVWo4CdESSfuSsB","gatewayRef":"bZQLD9wRVWo4CdESSfuSsB"}

# 2. Replay the same callback twice → order marked paid only once
$ curl -s -o /dev/null -w "%{redirect_url}\n" "http://localhost:4000/payments/khalti/callback?pidx=bZQLD9wRVWo4CdESSfuSsB"
http://localhost:3000/orders/66fa...?payment=completed
$ curl -s -o /dev/null -w "%{redirect_url}\n" "http://localhost:4000/payments/khalti/callback?pidx=bZQLD9wRVWo4CdESSfuSsB"
http://localhost:3000/orders/66fa...?payment=completed   # no second markPaid in logs

# 3. Tampered eSewa data (edited total_amount) → signature check fails
$ curl -s -o /dev/null -w "%{redirect_url}\n" "http://localhost:4000/payments/esewa/callback?data=eyJ0b3RhbF9hbW91bnQiOiIxLjAi..."
http://localhost:3000/checkout?payment=error
# server log: PaymentError: Invalid eSewa signature

# 4. Unknown gateway → 400
$ curl -i -X POST http://localhost:4000/payments/paypal/initiate -H "Authorization: Bearer $TOKEN"
HTTP/1.1 400 Bad Request
{"error":"Unsupported payment gateway"}

Production checklist for Nepal payment gateways

  • Never trust redirect parameters; always verify server-side with the gateway’s status, lookup, or verification API
  • Take the amount from your database, store it in paisa, and compare it with the verified amount before marking an order paid
  • Make order completion idempotent with a conditional update or unique constraint so duplicate callbacks cannot double-count revenue
  • Run a reconciliation job every few minutes for PENDING payments older than 10–15 minutes, using the same verify logic; customers often close the tab after paying
  • Keep secret keys in environment variables or a secrets manager, rotate them if they ever leak, and never ship them to the browser
  • Use HTTPS callback URLs in production and register the exact production domains with each gateway during merchant onboarding
  • Log every initiate, callback, and verification with the Payment ID and gateway reference—but never log full secret keys
  • Handle refunds through the merchant dashboards or refund APIs and record them as REFUNDED in your system so reports match settlements
  • Compare daily settlement reports from eSewa, Khalti, and your bank against your Payment table; the reconciliation guide covers this in depth

With this structure, adding a new wallet or bank later is one new adapter class and one line in the registry. If you want the next layer of safety, implement payment reconciliation on top of these adapters so no pending payment is ever forgotten.

I have built billing and payment flows for POS, ERP, and SaaS products—from purchase and sales billing to Stripe subscriptions and Xero reconciliation for international clients. If you need a checkout that handles eSewa, Khalti, and Fonepay correctly the first time—or want an audit of an existing integration—my Node.js developer services cover exactly this. Get in touch with your stack and the gateways you need, and we can plan an integration that your finance team will trust.