Web developers and students in Nepal building ecommerce sites, event booking, or SaaS apps that need to accept digital wallet payments securely.
- ✓Basic understanding of REST APIs and HTTP redirects
- ✓Node.js (v18+) or PHP 8.1+ with Laravel 10/11
- ✓Familiarity with environment variables (.env)
🚀 What You Will Learn & Build
- ▸How eSewa's v2 ePay payment lifecycle works
- ▸Generating the HMAC-SHA256 signature required by eSewa
- ▸Constructing and submitting the checkout payment request
- ▸Safely parsing the base64-encoded response payload
- ▸Server-side verification with eSewa's Status Check API
- ▸Preventing duplicate order processing and handling retries
1. Understanding eSewa's Payment Lifecycle
A malicious user can manually craft a redirect to your success URL. You MUST verify the transaction with eSewa's status API from your server before updating order status.
2. Test Credentials and Environment Configuration
# eSewa Sandbox Credentials
ESEWA_PRODUCT_CODE=EPAYTEST
ESEWA_SECRET_KEY=8gBm/:&EnhH.1/q
ESEWA_GATEWAY_URL=https://rc-epay.esewa.com.np/api/epay/main/v2/form
ESEWA_STATUS_CHECK_URL=https://rc-epay.esewa.com.np/api/epay/transaction/status/
APP_URL=http://localhost:30003. Generating the HMAC-SHA256 Signature
const crypto = require('crypto');
function generateEsewaSignature(secretKey, totalAmount, transactionUuid, productCode) {
// Format must strictly match: total_amount=X,transaction_uuid=Y,product_code=Z
const message = `total_amount=${totalAmount},transaction_uuid=${transactionUuid},product_code=${productCode}`;
const hash = crypto
.createHmac('sha256', secretKey)
.update(message)
.digest('base64');
return hash;
}
module.exports = { generateEsewaSignature };4. Creating the Payment Initiation Endpoint
const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { generateEsewaSignature } = require('../esewaSignature');
router.post('/initiate-esewa', async (req, res) => {
const { amount, productId } = req.body;
const totalAmount = Number(amount).toFixed(2);
const transactionUuid = `ORD-${Date.now()}-${uuidv4().substring(0, 8)}`;
const productCode = process.env.ESEWA_PRODUCT_CODE;
const secretKey = process.env.ESEWA_SECRET_KEY;
const signature = generateEsewaSignature(secretKey, totalAmount, transactionUuid, productCode);
const esewaPayload = {
amount: totalAmount,
tax_amount: "0",
total_amount: totalAmount,
transaction_uuid: transactionUuid,
product_code: productCode,
product_service_charge: "0",
product_delivery_charge: "0",
success_url: `${process.env.APP_URL}/api/payment/esewa/success`,
failure_url: `${process.env.APP_URL}/api/payment/esewa/failure`,
signed_field_names: "total_amount,transaction_uuid,product_code",
signature: signature
};
// Return payload to frontend to submit to eSewa
res.json({
gatewayUrl: process.env.ESEWA_GATEWAY_URL,
params: esewaPayload
});
});
module.exports = router;5. Handling Success Callback & Server-Side Verification
const axios = require('axios');
router.get('/esewa/success', async (req, res) => {
try {
const { data } = req.query;
if (!data) return res.status(400).send('Missing eSewa callback payload');
// Decode base64 payload
const decodedBuffer = Buffer.from(data, 'base64');
const callbackData = JSON.parse(decodedBuffer.toString('utf-8'));
const { total_amount, transaction_uuid, product_code } = callbackData;
// Verify with eSewa Server-to-Server Status API
const verifyUrl = `${process.env.ESEWA_STATUS_CHECK_URL}?product_code=${product_code}&total_amount=${total_amount}&transaction_uuid=${transaction_uuid}`;
const verifyRes = await axios.get(verifyUrl);
if (verifyRes.data.status === 'COMPLETE') {
// 1. Check if order was already marked completed (Idempotency)
// 2. Verify total_amount matches the amount in your database
// 3. Update order status to PAID
console.log('Payment verified successfully:', verifyRes.data);
return res.redirect(`/order-success?uuid=${transaction_uuid}`);
} else {
return res.redirect('/order-failed?reason=verification_failed');
}
} catch (err) {
console.error('eSewa verification error:', err.message);
return res.status(500).send('Transaction verification failed');
}
});✨ Expected Output & Verification
When the test transaction is complete: - eSewa redirects to: http://localhost:3000/api/payment/esewa/success?data=eyJ0cmFuc2FjdGlvbl9jb2Rl... - Server verifies with https://rc-epay.esewa.com.np/api/epay/transaction/status/ - Response returns HTTP 200 with JSON: { "product_code": "EPAYTEST", "total_amount": 100.0, "status": "COMPLETE", "ref_id": "0008544", "transaction_uuid": "ORD-1715420101-abc12" }
🧪 How to Test the Implementation
- 1Use test eSewa ID: 9806800001 / 9806800002 / 9806800003
- 2Password/MPIN: 1122
- 3OTP Code sent in sandbox: 123456
- 4Confirm amount deduction in test wallet and redirect to success URL
- 5Inspect server terminal logs to ensure HTTP GET status check returns COMPLETE
⚠️ Common Errors & How to Fix Them
Root Cause: Field ordering in string is incorrect, or amount formatted with differing decimals (e.g. 100 vs 100.00).
Fix: Format amount strictly to 2 decimal places and verify field sequence: total_amount,transaction_uuid,product_code.
Root Cause: Querying status check URL before the customer finished entering MPIN, or using mismatched transaction_uuid.
Fix: Ensure transaction_uuid is stored in session/DB and exactly matches the value returned in the decoded data query parameter.
Root Cause: Attempting to perform fetch() or axios.post() directly to eSewa form URL from JavaScript frontend.
Fix: eSewa v2 requires a standard browser navigation / form POST submission, not an AJAX request.
🛡️ Security Considerations & Best Practices
- !Never store the ESEWA_SECRET_KEY in frontend React or Next.js client bundles.
- !Always verify the total_amount in callback matches the original database order amount (avoids tampering).
- !Implement database locking (e.g. SELECT FOR UPDATE in MySQL) to avoid double crediting if callbacks fire multiple times.
❓ Frequently Asked Questions (FAQ)
Sign a merchant agreement with eSewa at merchant.esewa.com.np. They will issue your live Product Code and Secret Key. In your production .env, update ESEWA_PRODUCT_CODE, ESEWA_SECRET_KEY, and change the URLs from rc-epay to epay.esewa.com.np.
📌 Summary of Key Takeaways
- ✓eSewa v2 uses HMAC-SHA256 signatures with base64 encoding.
- ✓Required signature format: total_amount=X,transaction_uuid=Y,product_code=Z.
- ✓Always verify transaction status server-side using eSewa status check API.
- ✓Store merchant secrets securely in server environment variables.
Official References & Further Reading
- 🔗Official eSewa Developer Portal— Official API guides, parameter definitions, and test credentials.
- 🔗eSewa Test Credentials Documentation— Official sandbox wallet numbers, MPIN, and OTP rules.
Written by Manoj Neupane
AuthorSoftware developer and programming instructor, Nepal
Building software systems and SaaS platforms with Laravel, Next.js, and Node.js. Passionate about helping students and developers in Nepal master practical, real-world development skills.