PayBridgeNP

Developers

eSewa and Khalti in Node.js and Next.js

An eSewa or Khalti payment has to start on your server, not in the browser. How to do it in Next.js route handlers, with one SDK as the shorter path.

By PayBridgeNP Team10 min read

Can you integrate eSewa in React? Not in React alone. Both eSewa and Khalti need a secret key, and anything in a React bundle is public. The payment has to start and be confirmed on a server. In a Next.js app that server is right there, in route handlers. In a plain React app it is a small Node.js API next to it.

This guide shows both providers end to end in TypeScript, with Next.js App Router route handlers, then the shorter path through one SDK.

Last verified: 25 October 2026, against eSewa's ePay docs, Khalti's ePayment docs, the PayBridgeNP TypeScript SDK 5.7.0, and Node.js 20 or later (for the built-in fetch).

Ground rules

  • Secrets stay server-side. Never prefix them with NEXT_PUBLIC_, and never call eSewa or Khalti with a secret from client components.
  • Amounts in paisa in your database (10000 is Rs 100). Khalti takes paisa; eSewa takes a rupee string like "100.00".
  • A row for every payment attempt. A retry must not overwrite the first attempt, which can still complete. eSewa also rejects a reused transaction_uuid.
  • Only the order's owner starts a payment, and an order is marked paid exactly once.
  • Confirm before you fulfil. The redirect back to your site is not proof of payment.
# .env.local
ESEWA_PRODUCT_CODE=EPAYTEST
ESEWA_SECRET_KEY=...           # eSewa's test key in sandbox
ESEWA_BASE_URL=https://rc-epay.esewa.com.np   # live: https://epay.esewa.com.np
KHALTI_SECRET_KEY=...
KHALTI_BASE_URL=https://dev.khalti.com/api/v2 # live: https://khalti.com/api/v2
APP_URL=http://localhost:3000

Test credentials for both are in eSewa test credentials and Khalti test credentials.

The examples use a placeholder db for your own data layer and a currentUserId() from your auth. They assume three things about it:

  • db.attempts stores one row per payment attempt: the order, the provider, a unique reference (eSewa's uuid, Khalti's pidx or a checkout session ID), the amountPaisa that attempt asked for, and, for checkout sessions, an idempotencyKey. find(provider, reference) looks one up, findOrCreateOpen(orderId, provider, amountPaisa) returns the attempt still waiting for its session (or a new one, with a fresh idempotency key, if there is none for that amount), and setReference(id, reference) records the session once it exists. A failed or expired attempt is marked on the attempt, never on the order, so a newer attempt can still be paid.
  • db.orders.findPendingFor(orderId, userId) returns the order only if it is still pending and belongs to that user.
  • db.orders.markPaidOnce(orderId, paidPaisa, via, ref) runs one conditional update, UPDATE orders SET status = 'paid' ... WHERE id = $1 AND status = 'pending' AND amount_paisa = $2, and returns true only if it changed a row. Fulfil the order only when it returns true.

eSewa in Next.js

An eSewa ePay payment end to end: your server signs the form, the browser posts it to eSewa, the customer pays, eSewa redirects back with Base64 data you verify, your server calls the status check, and only COMPLETE means fulfil, once

eSewa needs the customer's browser to post a signed form to eSewa. So your server signs the fields, and the browser submits them.

Sign the form on the server

// app/api/pay/esewa/route.ts
import { createHmac, randomUUID } from "node:crypto";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const order = await db.orders.findPendingFor(
    orderId,
    await currentUserId(),
  );
  if (!order)
    return Response.json({ error: "Order not found" }, { status: 404 });

  // new for every attempt
  const uuid = `${order.id}-${randomUUID().slice(0, 8)}`;
  const total = (order.amountPaisa / 100).toFixed(2); // "100.00"
  await db.attempts.create({
    orderId: order.id,
    provider: "esewa",
    reference: uuid,
    amountPaisa: order.amountPaisa,
  });

  const code = process.env.ESEWA_PRODUCT_CODE!;
  const message = [
    `total_amount=${total}`,
    `transaction_uuid=${uuid}`,
    `product_code=${code}`,
  ].join(",");
  const signature = createHmac("sha256", process.env.ESEWA_SECRET_KEY!)
    .update(message)
    .digest("base64");

  return Response.json({
    action: `${process.env.ESEWA_BASE_URL}/api/epay/main/v2/form`,
    fields: {
      amount: total,
      tax_amount: "0",
      total_amount: total,
      transaction_uuid: uuid,
      product_code: code,
      product_service_charge: "0",
      product_delivery_charge: "0",
      success_url: `${process.env.APP_URL}/api/pay/esewa/return`,
      failure_url: `${process.env.APP_URL}/api/pay/esewa/return`,
      signed_field_names: "total_amount,transaction_uuid,product_code",
      signature,
    },
  });
}

Submit it from the browser

// a client component
"use client";

export function PayWithEsewa({ orderId }: { orderId: string }) {
  async function pay() {
    const res = await fetch("/api/pay/esewa", {
      method: "POST",
      body: JSON.stringify({ orderId }),
    });
    const { action, fields } = await res.json();

    const form = document.createElement("form");
    form.method = "POST";
    form.action = action;
    for (const [name, value] of Object.entries(
      fields as Record<string, string>,
    )) {
      const input = document.createElement("input");
      input.type = "hidden";
      input.name = name;
      input.value = value;
      form.appendChild(input);
    }
    document.body.appendChild(form);
    form.submit();
  }
  return <button onClick={pay}>Pay with eSewa</button>;
}

The signature is computed with your secret on the server; the browser only carries the finished fields.

Confirm the return

// app/api/pay/esewa/return/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function GET(req: Request) {
  const encoded = new URL(req.url).searchParams.get("data");
  if (!encoded) return Response.redirect(`${process.env.APP_URL}/cart`);

  let data: Record<string, string>;
  try {
    data = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
  } catch {
    return new Response("Bad response", { status: 400 });
  }
  if (!data || typeof data !== "object")
    return new Response("Bad response", { status: 400 });

  // 1. Check eSewa's signature on the response. Insist on the response's
  //    own signed field set, so signed *request* fields cannot be replayed.
  const RESPONSE_FIELDS = [
    "transaction_code",
    "status",
    "total_amount",
    "transaction_uuid",
    "product_code",
    "signed_field_names",
  ].join(",");
  if (data.signed_field_names !== RESPONSE_FIELDS)
    return new Response("Invalid response", { status: 400 });
  const message = RESPONSE_FIELDS.split(",")
    .map((f) => `${f}=${data[f] ?? ""}`)
    .join(",");
  const expected = Buffer.from(
    createHmac("sha256", process.env.ESEWA_SECRET_KEY!)
      .update(message)
      .digest("base64"),
  );
  const given = Buffer.from(String(data.signature ?? ""));
  if (expected.length !== given.length || !timingSafeEqual(expected, given)) {
    return new Response("Invalid signature", { status: 400 });
  }

  // 2. Load YOUR attempt by the uuid you stored.
  const attempt = await db.attempts.find("esewa", data.transaction_uuid);
  if (!attempt) return new Response("Unknown payment", { status: 404 });

  // 3. Ask eSewa, with the exact amount string this attempt sent.
  const qs = new URLSearchParams({
    product_code: process.env.ESEWA_PRODUCT_CODE!,
    total_amount: (attempt.amountPaisa / 100).toFixed(2),
    transaction_uuid: attempt.reference,
  });
  const status = await fetch(
    `${process.env.ESEWA_BASE_URL}/api/epay/transaction/status/?${qs}`,
    {
      signal: AbortSignal.timeout(20_000),
    },
  ).then((r) => r.json());

  // 4. Paid only if eSewa says COMPLETE for the amount this attempt asked for.
  const paidPaisa = Math.round(
    Number(String(status.total_amount ?? "").replace(/,/g, "")) * 100,
  );
  if (status.status === "COMPLETE" && paidPaisa === attempt.amountPaisa) {
    await db.orders.markPaidOnce(
      attempt.orderId,
      attempt.amountPaisa,
      "esewa",
      status.ref_id,
    );
  }
  return Response.redirect(
    `${process.env.APP_URL}/orders/${attempt.orderId}`,
  );
}

Khalti in Next.js

A Khalti ePayment payment end to end: your server calls initiate and stores the pidx, the customer pays on Khalti's page and returns with the pidx, your server calls lookup, and only Completed means fulfil, once

Khalti never needs the browser to sign anything: your server starts the payment and redirects.

// app/api/pay/khalti/route.ts
import { randomUUID } from "node:crypto";

const KHALTI = process.env.KHALTI_BASE_URL!;
const auth = { Authorization: `Key ${process.env.KHALTI_SECRET_KEY}` };

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const order = await db.orders.findPendingFor(
    orderId,
    await currentUserId(),
  );
  if (!order)
    return Response.json({ error: "Order not found" }, { status: 404 });

  const res = await fetch(`${KHALTI}/epayment/initiate/`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({
      return_url: `${process.env.APP_URL}/api/pay/khalti/return`,
      website_url: process.env.APP_URL, // required, never blank
      amount: order.amountPaisa, // paisa
      purchase_order_id: `${order.id}-${randomUUID().slice(0, 8)}`,
      purchase_order_name: `Order ${order.id}`,
    }),
    signal: AbortSignal.timeout(20_000),
  });
  if (!res.ok)
    return Response.json(
      { error: "Khalti did not start the payment" },
      { status: 502 },
    );

  const { pidx, payment_url } = await res.json();
  await db.attempts.create({
    orderId: order.id,
    provider: "khalti",
    reference: pidx,
    amountPaisa: order.amountPaisa,
  });
  // the client sets window.location to this
  return Response.json({ paymentUrl: payment_url });
}
// app/api/pay/khalti/return/route.ts
export async function GET(req: Request) {
  const pidx = new URL(req.url).searchParams.get("pidx");
  // only a pidx YOU issued
  const attempt = pidx ? await db.attempts.find("khalti", pidx) : null;
  if (!attempt) return new Response("Unknown payment", { status: 404 });

  const lookup = await fetch(
    `${process.env.KHALTI_BASE_URL}/epayment/lookup/`,
    {
      method: "POST",
      headers: {
        Authorization: `Key ${process.env.KHALTI_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ pidx: attempt.reference }),
      signal: AbortSignal.timeout(20_000),
    },
  ).then((r) => r.json());

  const paid =
    lookup.status === "Completed" &&
    !lookup.refunded &&
    lookup.total_amount === attempt.amountPaisa;
  if (paid)
    await db.orders.markPaidOnce(
      attempt.orderId,
      attempt.amountPaisa,
      "khalti",
      lookup.transaction_id,
    );

  return Response.redirect(
    `${process.env.APP_URL}/orders/${attempt.orderId}`,
  );
}

If Khalti's answer to the initiate call never arrives, the customer never receives a payment link, so nothing can be paid on that attempt; starting again is safe. Pending and Initiated are not failures: leave the order pending and look it up again later. If the lookup itself fails, the customer may still have paid. Every status is explained in the Khalti API integration guide.

Plain React with an Express API

The same logic works in Express, adapted to Express's request and response objects: read JSON bodies with express.json() (and the webhook's raw body with express.raw()), build absolute URLs from your own APP_URL, and answer with res.json() and res.redirect() instead of returning a Response. Keep the secrets in the API's environment and call the API from your React app. The React side only ever receives the eSewa form fields or the Khalti payment_url.

What is still missing

The code above has no answer for a customer who pays and closes the browser before the return page loads. You need a scheduled job that looks up every attempt not yet confirmed or ruled out, even after its order is paid, so a second payment from an old attempt is caught and refunded. Refunds, Fonepay's QR flow and one view of all payments are separate work again.

The shorter path: one SDK for eSewa, Khalti and Fonepay

With PayBridgeNP, you connect your own eSewa, Khalti and Fonepay merchant accounts once and use one API:

npm install @paybridge-np/sdk
// app/api/checkout/route.ts
import { PayBridgeNP } from "@paybridge-np/sdk";

const paybridgenp = new PayBridgeNP({
  apiKey: process.env.PAYBRIDGENP_API_KEY!,
}); // sk_test_... in sandbox

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const order = await db.orders.findPendingFor(
    orderId,
    await currentUserId(),
  );
  if (!order)
    return Response.json({ error: "Order not found" }, { status: 404 });

  // Record the attempt BEFORE creating the session, with its own idempotency
  // key, so a timeout and retry returns the same session, not a second one.
  // findOrCreateOpen reuses an open attempt only for the same amount, inside
  // one transaction that locks the order row: two taps, one attempt.
  const attempt = await db.attempts.findOrCreateOpen(
    order.id,
    "paybridgenp",
    order.amountPaisa,
  );

  const session = await paybridgenp.checkout.create(
    {
      amount: attempt.amountPaisa,
      currency: "NPR",
      returnUrl: `${process.env.APP_URL}/orders/${order.id}`,
      cancelUrl: `${process.env.APP_URL}/cart`,
      metadata: { orderId: order.id },
    },
    attempt.idempotencyKey,
  );

  // The session ID is the link between this order and the webhook.
  await db.attempts.setReference(attempt.id, session.id);
  return Response.json({ checkoutUrl: session.checkout_url });
}
// app/api/webhooks/paybridgenp/route.ts
import { PayBridgeNP } from "@paybridge-np/sdk";

type PaymentSucceeded = {
  id: string;
  amount: number;
  currency: string;
  session_id: string;
  provider_ref?: string;
};
// true only with an sk_live_ key
const LIVE = process.env.PAYBRIDGENP_LIVE === "true";

export async function POST(req: Request) {
  const body = await req.text(); // the raw body, before any JSON parsing
  let event;
  try {
    event = await PayBridgeNP.webhooks.constructEvent<PaymentSucceeded>(
      body,
      req.headers.get("x-paybridgenp-signature"),
      process.env.PAYBRIDGENP_WEBHOOK_SECRET!,
    );
  } catch {
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }

  // A sandbox event must never mark a real order paid, and the reverse.
  if (event.livemode !== LIVE)
    return Response.json({ ignored: "wrong mode" });

  if (event.type === "payment.succeeded") {
    const attempt = await db.attempts.find(
      "paybridgenp",
      event.data.session_id,
    );
    if (
      attempt &&
      event.data.amount === attempt.amountPaisa &&
      event.data.currency === "NPR"
    ) {
      await db.orders.markPaidOnce(
        attempt.orderId,
        event.data.amount,
        "paybridgenp",
        event.data.provider_ref ?? null,
      );
    }
  }
  return Response.json({ received: true });
}

The customer picks eSewa, Khalti or Fonepay on a hosted page, and you get one signed payment.succeeded webhook whichever they chose. The signature check rejects anything not signed with your webhook secret, and messages more than five minutes old. The same event can still arrive twice, for example on a retried delivery, which is why the handler finds the attempt by the session ID you stored and marks the order paid only through markPaidOnce. Sandbox keys are free on every plan, eSewa and Khalti work in test mode without provider keys of your own, and payments settle straight into your own merchant accounts. PayBridgeNP never holds the money.

Next Steps


Ready to accept payments in Nepal?

Connect eSewa, Khalti, and Fonepay with a single API. Free to start, with no fund-holding and no new bank accounts.