Introduction
Fonepay is one of Nepal's leading digital payment platforms. It lets customers pay by scanning a QR code with their mobile banking or wallet app.
In this guide, I'll walk you through how I integrated Fonepay Dynamic QR payments using Node.js and TypeScript. We will cover only the QR-based flow:
- Generate a dynamic QR for an order
- Let the customer scan and pay
- Verify whether the payment was successful
Note: This is the simple, minimum amount of code needed to generate a QR and verify the payment. Add your own validation ,error handling and restrictions as per your needs.
A dynamic QR is generated fresh for every transaction, with the amount and a unique reference already embedded. The customer just scans and confirms; they don't need to type the amount.
How the Flow Works
Before writing any code, it helps to see the whole picture:
Frontend (Checkout)
↓
POST /fonepay/dynamic-qr
↓
Backend creates PRN + signature
↓
Fonepay API → returns QR data
↓
Frontend shows QR → customer scans and pays
↓
Backend verifies payment using PRN
↓
Order marked as paid ✅
The most important piece in this whole flow is the PRN (Payment Reference Number). We create it when generating the QR, and we use the same PRN later to check whether the payment succeeded.
Prerequisites
Before diving into the implementation, make sure you have:
- Node.js (v18 or higher)
- A Fonepay merchant account with API credentials (username, password, merchant code and secret key)
- A basic understanding of REST APIs
- Basic familiarity with HMAC signatures (we'll use HMAC-SHA512)
Environment Configuration
First, let's set up the environment variables. Create a .env file in your project root:
FONEPAY_DYNAMIC_QR_URL=https://merchantapi.fonepay.com/api/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload
FONEPAY_QR_STATUS_URL=https://merchantapi.fonepay.com/api/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrGetStatus
FONEPAY_USERNAME=your_username
FONEPAY_PASSWORD=your_password
FONEPAY_MERCHANT_CODE=your_merchant_code
FONEPAY_SECRET_KEY=your_secret_key
Never commit this file. Add
.envto your.gitignore.
Install Dependencies
npm install axios dotenv
crypto is built into Node.js, so we don't need to install it.
Service Structure
I went with a class-based approach and created three static methods:
class FonepayDynamicQrService {
static signature() {}
static async generateDynamicQr() {}
static async verifyPayment() {}
}
signature()creates the HMAC hash. Making it a separate method was optional, but both API calls need it, so I made it reusable instead of repeating the same code.generateDynamicQr()creates the QR for a payment.verifyPayment()checks the payment status using the PRN.
Loading the Config Safely
Before writing the service, I load all the credentials in one place. If any variable is missing, the app fails immediately at startup instead of failing later in the middle of a payment.
import "dotenv/config";
import axios from "axios";
import crypto from "crypto";
function getEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing environment variable: ${name}`);
}
return value;
}
const fonepayConfig = {
qrUrl: getEnv("FONEPAY_DYNAMIC_QR_URL"),
statusUrl: getEnv("FONEPAY_QR_STATUS_URL"),
username: getEnv("FONEPAY_USERNAME"),
password: getEnv("FONEPAY_PASSWORD"),
merchantCode: getEnv("FONEPAY_MERCHANT_CODE"),
secretKey: getEnv("FONEPAY_SECRET_KEY"),
};
This also fixes a TypeScript annoyance: process.env.SOMETHING is typed as string | undefined, but getEnv() always returns a string.
1. The Signature Helper
Fonepay uses a field called dataValidation to make sure our request was not tampered with. It is an HMAC-SHA512 hash of specific values joined with commas, signed with our secret key.
static signature({ secretKey, data }: { secretKey: string; data: string }) {
return crypto.createHmac("sha512", secretKey).update(data).digest("hex");
}
That's it. We pass in a string and a secret key, and get back a hex signature.
2. Generating the Dynamic QR
static async generateDynamicQr({
orderId,
amount,
remarks1 = "",
remarks2 = "",
}: {
orderId: string;
amount: number;
remarks1?: string;
remarks2?: string;
}) {
// ...
}
All of these parameters come from the controller (req.body).
Step 1: Create a unique PRN
const prn = `BWS_${orderId}_${Date.now()}`;
The PRN must be unique for every transaction. I combine a short prefix, the order ID and a timestamp. If the customer generates a new QR for the same order, they still get a new PRN.
Step 2: Generate the data validation hash
For QR generation, the values are joined in this order:
amount,prn,merchantCode,remarks1,remarks2
const dataValidation = this.signature({
secretKey: fonepayConfig.secretKey,
data: [amount, prn, fonepayConfig.merchantCode, remarks1, remarks2].join(","),
});
Notice that remarks1 and remarks2 default to "". This matters: the values we sign must be exactly the same as the values we send. If we sign an empty string but send undefined, Fonepay will calculate a different hash and reject the request.
Step 3: Build the payload and call Fonepay
const payload = {
amount,
remarks1,
remarks2,
prn,
merchantCode: fonepayConfig.merchantCode,
dataValidation,
username: fonepayConfig.username,
password: fonepayConfig.password,
};
const { data } = await axios.post(fonepayConfig.qrUrl, payload, {
headers: { "Content-Type": "application/json" },
timeout: 30_000,
});
return {
success: true,
prn,
amount,
qrCodeData: data,
};
Fonepay returns the QR data, which the frontend renders as a QR image. If the request fails, axios throws, and we handle that error in the controller.
Key Points About QR Payments
- PRN (Payment Reference Number): a unique identifier for each transaction
- Data validation: an HMAC-SHA512 hash that ensures request integrity
- Dynamic QR: the QR has the payment information embedded in it
- Real-time generation: a new QR is generated for every transaction
3. Verifying the Payment
As I said earlier, the PRN is the key to finding out whether a payment was successful.
For verification, the signature is simpler. We only sign the PRN and the merchant code:
prn,merchantCode
static async verifyPayment({ prn }: { prn: string }) {
const dataValidation = this.signature({
secretKey: fonepayConfig.secretKey,
data: [prn, fonepayConfig.merchantCode].join(","),
});
const payload = {
prn,
merchantCode: fonepayConfig.merchantCode,
dataValidation,
username: fonepayConfig.username,
password: fonepayConfig.password,
};
const { data } = await axios.post(fonepayConfig.statusUrl, payload, {
headers: { "Content-Type": "application/json" },
timeout: 30_000,
});
return {
...data,
success: data.paymentStatus === "success",
status: data.paymentStatus,
};
}
Here I get the PRN from the controller through route params, but you can pass it however you like.
The Controller
A minimal Express controller to connect everything:
import { Request, Response } from "express";
export async function createDynamicQr(req: Request, res: Response) {
try {
const { orderId, amount, remarks1, remarks2 } = req.body;
const result = await FonepayDynamicQrService.generateDynamicQr({
orderId,
amount: Number(amount),
remarks1,
remarks2,
});
return res.status(200).json({ data: result });
} catch (error) {
return res.status(500).json({ message: "Failed to generate QR" });
}
}
export async function verifyDynamicQr(req: Request, res: Response) {
try {
const result = await FonepayDynamicQrService.verifyPayment({
prn: req.params.prn,
});
return res.status(200).json({ data: result });
} catch (error) {
return res.status(500).json({ message: "Failed to verify payment" });
}
}
Calling It From the Frontend
On the frontend, I call the API and store the result, so I can show the QR and keep the PRN for verification:
const handleGenerateQr = async () => {
setLoading(true);
try {
const values = form.getFieldsValue();
const payload = {
orderId: `Test_${Date.now()}`, // must be unique for each transaction
amount: values.amount,
remarks1: "Test_remarks1",
remarks2: "Test_remarks2",
};
const response = await axiosInstance.post(
"/integrations/fonepay/dynamic-qr",
payload,
);
setTestResult(response?.data?.data);
setPaymentStatus(DYNAMIC_QR_PAYMENT_STATUS.ENTR); // QR generated, waiting for scan
setPaymentUpdate(null);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
To make the experience better, you can use sockets to show live updates on the frontend, such as:
QR generated → QR scanned → Processing → Success / Failed
The customer sees what is happening instead of staring at a static QR.
Production Checklist
As I mentioned at the start, the code above is the minimum needed to make it work. These are the things I'd add before going live:
1. Never trust the amount from the frontend.
In the example above, the amount comes from a form, which is fine for testing. In a real checkout, the frontend should send only the orderId. The backend should load the order from the database and use that amount. Otherwise, anyone can open dev tools and pay Rs. 1 for a Rs. 10,000 order.
2. Save the PRN with the order. Store the PRN, amount and status in your database when you generate the QR. The status response doesn't tell you which order the payment belongs to; your database does.
3. Socket updates are for the UI, not for settlement.
Live updates are great for showing progress, but before marking an order as paid, always call verifyPayment() from your backend and confirm the status is success.
4. Make verification idempotent. Verification might run more than once (a page refresh, a retry, a socket event plus a manual check). If the order is already marked as paid, don't process it again.
5. Keep the PRN short. Fonepay has a length limit on the PRN, so check it in your Fonepay documentation, especially if your order IDs are long (like MongoDB ObjectIds).
Final Takeaway
The mental model I keep in mind for Fonepay Dynamic QR:
generateDynamicQr()
↓
Create unique PRN
↓
Sign: amount,prn,merchantCode,remarks1,remarks2
↓
Fonepay returns QR data
↓
Customer scans and pays
↓
verifyPayment()
↓
Sign: prn,merchantCode
↓
paymentStatus === "success" → mark order as paid
Once you understand that the PRN ties everything together and that the signed values must match the sent values exactly, the rest of the Fonepay integration becomes very straightforward.
