Most eSewa and Khalti integrations I have seen follow the same pattern: send the customer to the gateway, wait for the success redirect, mark the order paid. It works in every demo. It is also the bug.
This post walks through how the two gateways actually behave, six mistakes that cost real money, and a Next.js integration that avoids them. Everything here was checked against the eSewa and Khalti sandboxes, including completed payments. The code uses nepal-pay, a small open source SDK I wrote for both gateways, but the rules apply whatever you use.
How the two gateways work
eSewa (ePay v2)
- Your server signs a form with HMAC-SHA256, and the browser POSTs it to eSewa.
- The customer pays. eSewa redirects to your success URL with
?data=, a base64 JSON object with its own signature. - Your server asks eSewa's status API about the transaction.
Khalti (ePayment)Local sandbox demo. Orders live in memory; do not deploy this as is.
Payment complete
Order ORD-3d2474aa885b457c, Rs 100: PAID
Checked with eSewa: COMPLETED
Gateway Attempt Status
esewa ORD-3d2474aa885b457c-phwqt1xv COMPLETED
New order
- Your server calls Khalti's initiate API with your secret key and gets back a
pidxand apayment_url. - The customer pays on that page. Khalti redirects to your return URL with
pidx,status, the amount and more in the query string. - Your server calls Khalti's lookup API with the
pidx.
In both, step 3 is the one that counts. The redirect in step 2 travels through the customer's browser, so the customer controls it.
Mistake 1: trusting the redirect
Khalti's return URL is not signed at all. Anyone can open /khalti/callback?pidx=...&status=Completed in a browser. If that marks an order paid, the shop gives things away.
eSewa's callback is signed, which feels safer, but there is a catch: the callback lists its own signed fields in signed_field_names. The form you sent at the start was also signed, over total_amount, transaction_uuid and product_code, and the customer can read that signature in the page source. A verifier that trusts "whatever fields the callback says are signed" accepts a callback that reuses the form's signature, lists those three fields, and adds "status": "COMPLETE" unsigned. The signature has to cover the status, the amount, the transaction id and the product code. (The first release of nepal-pay had exactly this bug; 0.1.1 fixed it.)
Even a correctly signed callback is only a claim. Mark an order paid only after the gateway's own API says so.
Mistake 2: checking the amount against the callback
Compare against your own order, never against the redirect.
- eSewa's status API takes the amount as part of the lookup. Ask with the wrong amount and it answers
NOT_FOUND, even for a completed payment. We confirmed this with a real sandbox payment. - Khalti's lookup returns
total_amountin paisa, so Rs 100 comes back as10000. Compare in the same unit.
Mistake 3: using the order id for every attempt
Customers abandon the payment page, press back, run out of balance and try again. Two gateway behaviours make retries dangerous:
- eSewa rejects a reused
transaction_uuidwithDuplicate transaction UUID., even when the first attempt was abandoned. Each attempt needs its own id, not the order id. - An abandoned eSewa attempt stays
PENDING, and a Khalti payment page stays payable for 30 minutes in the sandbox (60 by default in production). The first attempt can still be paid after the customer has started a second one.
So store one record per attempt, give each its own id, and before starting a new attempt, verify the earlier ones. If one completed late, the order is already paid: do not send the customer to pay again. If two complete anyway, flag the order for a refund.
Guard against two starts at the same moment as well (a double click, two tabs): a row lock or a conditional update on the order is enough.
Mistake 4: matching a Khalti return by the wrong field
Khalti's return URL includes purchase_order_id, and it is tempting to find the order with it. But the URL is unsigned, and Khalti's lookup API does not return your order id at all. The link between a payment and an order is the pidx you stored when you called initiate. Look the attempt up by pidx; if you never issued that pidx, answer 404.
Mistake 5: treating a timeout as a failure
When the status check times out, you do not know whether the customer paid. Do not mark the order failed. Keep the attempt and check it again later: on the next callback, from a "check payment" button, or from a scheduled job that catches customers who never come back to your site.
Khalti's initiate has a nastier case. If its reply is lost, Khalti may already have created a pidx, and Khalti has no idempotency key and no lookup by your order id, so you cannot find it. The good news: the payment URL was in the lost reply, so the customer never saw that page and it expires unpaid. Start a new attempt with a new id, and never retry initiate automatically.
Mistake 6: parsing eSewa's callback with JSON.parse
eSewa signs the exact text of each value: a total of 100.0 is signed as 100.0. If your code turns it into the number 100 and rebuilds the signed message, the signature no longer matches, and the tempting "fix" is to stop checking it. eSewa's documentation shows total_amount as a JSON number, while a real sandbox callback sends it as the string "100.0". Handle both, and keep the raw text for the signature check.
A Next.js integration
npm i @nepalpay/esewa @nepalpay/khalti
Both clients use only fetch and Web Crypto. They take the environment explicitly, so there is no silent default between test and real money.
// lib/payments.ts (server code only)
import { createEsewa } from '@nepalpay/esewa';
import { createKhalti } from '@nepalpay/khalti';
export const APP_URL = process.env.APP_URL!; // https://shop.example in production
export const esewa = createEsewa({
environment: 'sandbox',
productCode: process.env.ESEWA_PRODUCT_CODE!,
secretKey: process.env.ESEWA_SECRET_KEY!,
});
export const khalti = createKhalti({
environment: 'sandbox',
secretKey: process.env.KHALTI_SECRET_KEY!,
websiteUrl: APP_URL,
});
Keep these in route handlers, server actions or server components. The clients throw if they ever run in a browser.
In the snippets below, db stands for your own storage: one record per order and one per payment attempt.
Starting an eSewa payment
eSewa needs a POST from the browser, so the route answers with a form that submits itself.
// app/orders/[id]/pay/esewa/route.ts
import { paymentAttemptId } from '@nepalpay/esewa';
import { APP_URL, esewa } from '@/lib/payments';
import { db } from '@/lib/db';
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = await db.orders.find(id);
// A full app first re-verifies earlier attempts here (mistake 3).
if (!order || order.status !== 'UNPAID') return Response.redirect(`${APP_URL}/orders/${id}`, 303);
const attemptId = paymentAttemptId(order.id);
await db.attempts.create({ id: attemptId, orderId: order.id, gateway: 'esewa', status: 'STARTED' });
const form = await esewa.initiate({
amount: order.amount, // rupees
orderId: attemptId,
returnUrl: `${APP_URL}/esewa/callback`,
failureUrl: `${APP_URL}/esewa/failure/${attemptId}`,
});
return new Response(autoPostForm(form.action, form.fields), {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
}
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function autoPostForm(action: string, fields: Record<string, string>) {
const inputs = Object.entries(fields)
.map(([name, value]) => `<input type="hidden" name="${esc(name)}" value="${esc(value)}">`)
.join('');
return `<!doctype html><form id="pay" method="post" action="${esc(action)}">${inputs}</form>
<script>document.getElementById('pay').submit()</script>`;
}
The eSewa callback
// app/esewa/callback/route.ts
import { isPaymentError } from '@nepalpay/esewa';
import { APP_URL, esewa } from '@/lib/payments';
import { db } from '@/lib/db';
export async function GET(req: Request) {
try {
const data = new URL(req.url).searchParams.get('data') ?? '';
const claim = await esewa.parseCallback({ data }); // throws on a bad or partial signature
const attempt = await db.attempts.find(claim.reference);
if (!attempt || attempt.gateway !== 'esewa') return new Response('Unknown payment', { status: 404 });
const order = await db.orders.find(attempt.orderId);
// The decision comes from eSewa's status API, with the amount from your own order.
const result = await esewa.verify({ orderId: attempt.id, amount: order.amount });
await db.attempts.update(attempt.id, { status: result.status, transactionId: result.transactionId });
if (result.status === 'COMPLETED') await db.orders.markPaid(order.id, attempt.id);
return Response.redirect(`${APP_URL}/orders/${order.id}`, 303);
} catch (err) {
if (isPaymentError(err) && err.retryable) {
// Network error, timeout or a 5xx: the payment is unknown, not failed (mistake 5).
return new Response('eSewa did not answer. Your order was not changed; check it again shortly.', { status: 503 });
}
throw err;
}
}
markPaid should be idempotent: a callback can arrive twice, and an order paid by a second attempt is a refund case, not a second shipment.
Khalti
Khalti hands back a URL instead of a form, and the pidx it returns is the id you store.
// app/orders/[id]/pay/khalti/route.ts (after the same order checks as eSewa)
const started = await khalti.initiate({
amount: order.amount, // rupees; the client converts to paisa
orderId: paymentAttemptId(order.id),
returnUrl: `${APP_URL}/khalti/callback`,
productName: `Order ${order.id}`,
});
await db.attempts.create({ id: started.reference, orderId: order.id, gateway: 'khalti', status: 'STARTED' });
return Response.redirect(started.url, 303);
// app/khalti/callback/route.ts (inside the same try/catch as eSewa)
const claim = await khalti.parseCallback(new URL(req.url).searchParams);
const attempt = await db.attempts.find(claim.reference); // the pidx you stored, never purchase_order_id
if (!attempt || attempt.gateway !== 'khalti') return new Response('Unknown payment', { status: 404 });
const order = await db.orders.find(attempt.orderId);
const result = await khalti.verify({ pidx: attempt.id, expectedAmount: order.amount });
if (result.status === 'COMPLETED') await db.orders.markPaid(order.id, attempt.id);
verify() returns the same statuses for both gateways (COMPLETED, PENDING, FAILED, CANCELED, REFUNDED). If Khalti reports a completed payment for a different amount than your order, it throws AMOUNT_MISMATCH; eSewa's lookup includes the amount, so there a wrong amount comes back as FAILED.
The complete Next.js example adds the parts left out here: re-verifying earlier attempts, one payment start per order at a time, an attempt limit, the eSewa failure route and outage pages. It has its own tests, and real sandbox payments for both gateways went through its production build. There is an Express version too.
Testing in the sandbox
These are the sandbox logins used for the payments in this post:
| Gateway | Merchant side | Customer login |
|---|---|---|
| eSewa | Product code EPAYTEST, secret 8gBm/:&EnhH.1/q
|
eSewa ID 9711111111, MPIN 1122
|
| Khalti | Your own test key from test-admin.khalti.com | Khalti ID 9800000001, MPIN 1111 (OTP 987654 if asked) |
Things worth trying before you go live: pay and come back; cancel on the gateway's page; close the tab mid-payment and pay a second time; open the callback URL with a changed status; and cut your server's network during the status check.
Before production
- Production keys in environment variables, separate from sandbox keys, and
environment: 'production'. - HTTPS callback URLs (the clients refuse
httpin production). - An order is paid only from
verify(), never from the redirect. - One attempt record per payment try, stored before the redirect.
- A scheduled job that re-verifies unfinished attempts, for customers who never return to your site.
- A way to refund orders paid twice.
Links
- Repository: github.com/anishbhattarai-asm/nepal-pay
- npm: @nepalpay/esewa, @nepalpay/khalti
nepal-pay is unofficial and not affiliated with eSewa or Khalti. Issues and pull requests are welcome, especially from anyone who has run either gateway in production and seen behaviour that differs from the sandbox.
Top comments (0)