Command Palette

Search for a command to run...

Blog

Integrating Fonepay with NestJS — QR, Web Redirect

Integrating Fonepay with NestJS — QR, Web Redirect

A production-grade guide to wiring Fonepay — Nepal's interbank QR and payment network — into a NestJS (TypeScript) backend. HMAC-SHA512 signing done right, dynamic QR with real-time WebSocket confirmation, the web-redirect flow, and the server-side verification, idempotency, and amount checks most guides leave out.

Almost every Nepali product eventually has to take money, and in Nepal that means Fonepay. The integration itself is small — a few signed HTTP calls — but the tutorials I keep finding stop at "generate the hash and redirect." They skip the part that actually matters: never trusting the amount the browser hands you, verifying the outcome server-to-server, and making sure a refreshed tab can't fulfil an order twice.

This is the version I wish I'd had: idiomatic NestJS on Node 24, zero crypto dependencies, both flows (dynamic QR and web redirect), and the production hardening written down instead of assumed.


What Fonepay actually is

Fonepay is a vertical of F1Soft and Nepal's largest payment network, licensed by Nepal Rastra Bank as a Payment System Operator. It's an interoperable EMV QR + interbank (IBFT) rail reaching ~64 banks and wallets, so a single Fonepay QR is scannable from nearly every mobile-banking app in the country. Transactions are NPR (the QR also accepts Indian UPI), with a typical customer-side daily cap of NPR 200,000.

You don't self-serve API keys. You enrol as a merchant through a supporting acquiring bank, and Fonepay (or the bank) issues your merchant code, secret key, and a username/password. There are separate dev and live credentials — but the sandbox only exposes a single "test bank", so the full set of payment methods only lights up in production.


The two integration paths

Fonepay gives you two completely separate APIs on two different hosts. Pick by where the customer is paying.

Dynamic QRWeb Redirect (PG)
Hostmerchantapi.fonepay.comclientapi.fonepay.com
Customer pays byScanning a QR in any banking appBeing redirected to Fonepay's page
Best forIn-app checkout, POS, "scan to pay"Classic web checkout button
ConfirmationWebSocket push + status pollBrowser redirect + server verify
Response formatJSONXML (verification call)

Both are signed the same way, so let's build that primitive once.


The signing primitive: HMAC-SHA512

Every Fonepay request carries a dataValidation / DV field: an HMAC-SHA512 of specific fields joined by commas (no spaces), keyed by your secret. Two rules decide whether it works:

  1. Field order is exact and non-negotiable. A reordered field produces a valid-looking hash that Fonepay rejects.
  2. Sign the raw values, then URL-encode for transport. Hash first, encode second — never the other way around.

node:crypto covers all of it; there's nothing to install.

fonepay/signing.ts
import { createHmac, timingSafeEqual } from "node:crypto"
 
/** HMAC-SHA512 of `message`, keyed by the merchant secret, as lowercase hex. */
export function sign(secret: string, message: string): string {
  return createHmac("sha512", secret).update(message, "utf8").digest("hex")
}
 
/**
 * Constant-time comparison of two hex strings.
 *
 * Do NOT use `a === b` or `a.toUpperCase() === b.toUpperCase()` — string
 * equality short-circuits on the first differing byte and leaks, via timing,
 * how many leading characters matched. That's enough to forge a signature
 * byte by byte.
 */
export function safeHexEqual(a: string, b: string): boolean {
  if (typeof a !== "string" || typeof b !== "string") return false
 
  const bufA = Buffer.from(a, "hex")
  const bufB = Buffer.from(b, "hex")
 
  // timingSafeEqual throws RangeError on a length mismatch. Length isn't
  // secret, so short-circuiting here is safe — and saves you a 500.
  if (bufA.length === 0 || bufA.length !== bufB.length) return false
 
  return timingSafeEqual(bufA, bufB)
}

Configuration

One typed config object, fed from the environment. Secrets never leave the server, and .env never enters git.

fonepay/config.ts
export const fonepay = {
  // Web Redirect (PG) — clientapi
  pgBaseUrl: process.env.FONEPAY_PG_URL!, // https://dev-clientapi.fonepay.com
  // Dynamic QR — merchantapi
  qrBaseUrl: process.env.FONEPAY_QR_URL!, // https://uat-new-merchant-api.fonepay.com/api
 
  merchantCode: process.env.FONEPAY_MERCHANT_CODE!, // PID
  secret: process.env.FONEPAY_SECRET!, // never sent in any payload
  username: process.env.FONEPAY_USERNAME!,
  password: process.env.FONEPAY_PASSWORD!,
 
  returnUrl: process.env.FONEPAY_RETURN_URL!, // public HTTPS callback
} as const
.env
# Sandbox hosts — swap for the live hosts in production.
FONEPAY_PG_URL=https://dev-clientapi.fonepay.com
FONEPAY_QR_URL=https://uat-new-merchant-api.fonepay.com/api
 
FONEPAY_MERCHANT_CODE=NBQM
FONEPAY_SECRET=your_merchant_secret_key
FONEPAY_USERNAME=your_username
FONEPAY_PASSWORD=your_password
FONEPAY_RETURN_URL=https://your-tunnel.example.com/payments/fonepay/return

Node 24 reads .env natively — node --env-file=.env server.js. No dotenv.


Flow A: Dynamic QR

The shape: you ask Fonepay for a QR, render the returned string as an image, and the customer scans it. Confirmation arrives two ways — a real-time WebSocket push (great UX) and a status endpoint (your source of truth).

1. Generate the QR

POST /merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload. The dataValidation signs amount,prn,merchantCode,remarks1,remarks2 — note the PRN sits before the merchant code.

fonepay/qr.ts
import { fonepay } from "./config"
import { sign } from "./signing"
 
type QrResult = {
  qrMessage: string // encode THIS as the QR image
  thirdpartyQrWebSocketUrl: string // open for real-time push
  statusCode: number
}
 
export async function createDynamicQr(input: {
  prn: string // unique per attempt — you generate it
  amount: number // derived server-side, never from the client
  remarks1: string
  remarks2: string
}): Promise<QrResult> {
  const { prn, amount, remarks1, remarks2 } = input
 
  // EXACT order: amount, prn, merchantCode, remarks1, remarks2
  const dataValidation = sign(
    fonepay.secret,
    `${amount},${prn},${fonepay.merchantCode},${remarks1},${remarks2}`
  )
 
  const res = await fetch(
    `${fonepay.qrBaseUrl}/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        amount,
        remarks1,
        remarks2,
        prn,
        merchantCode: fonepay.merchantCode,
        dataValidation,
        username: fonepay.username,
        password: fonepay.password,
      }),
      signal: AbortSignal.timeout(15_000),
    }
  )
 
  // fetch does NOT reject on 4xx/5xx — you must check this yourself.
  if (!res.ok) throw new Error(`QR download failed: HTTP ${res.status}`)
  return (await res.json()) as QrResult
}

Turn qrMessage into a scannable image with the one dependency this guide needs:

terminal
pnpm add qrcode
fonepay/render-qr.ts
import QRCode from "qrcode"
 
export const toDataUrl = (qrMessage: string) => QRCode.toDataURL(qrMessage)

2. Confirm in real time, verify for real

The download response includes thirdpartyQrWebSocketUrl. The browser opens it for instant "Paid!" feedback the moment the customer's bank confirms — but the browser is untrusted, so the server still polls the status endpoint before it believes anything.

fonepay/qr-status.ts
import { fonepay } from "./config"
import { sign } from "./signing"
 
type QrStatus = {
  paymentStatus: "success" | "pending" | "failed"
  fonepayTraceId: number
  prn: string
}
 
export async function getQrStatus(prn: string): Promise<QrStatus> {
  // EXACT order: prn, merchantCode
  const dataValidation = sign(fonepay.secret, `${prn},${fonepay.merchantCode}`)
 
  const res = await fetch(
    `${fonepay.qrBaseUrl}/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrGetStatus`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        prn,
        merchantCode: fonepay.merchantCode,
        dataValidation,
        username: fonepay.username,
        password: fonepay.password,
      }),
      signal: AbortSignal.timeout(15_000),
    }
  )
 
  if (!res.ok) throw new Error(`QR status failed: HTTP ${res.status}`)
  return (await res.json()) as QrStatus
}

On the client, the WebSocket is purely for UX:

client/qr-listener.ts
// Browser side — instant feedback only. The server decides if the order is paid.
const ws = new WebSocket(thirdpartyQrWebSocketUrl)
ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data)
  const tx =
    typeof msg.transactionStatus === "string"
      ? JSON.parse(msg.transactionStatus)
      : msg.transactionStatus
  if (tx?.paymentSuccess) {
    // Show a spinner, then poll your own backend, which calls getQrStatus().
  }
}

Flow B: Web Redirect (PG)

Here the customer leaves your site for Fonepay's hosted page and comes back. You build one signed URL, redirect, then verify on return.

1. Build the signed redirect URL

The request DV signs PID,MD,PRN,AMT,CRN,DT,R1,R2,RU. Mind the details: MD is always P, CRN is NPR, and DT is MM/DD/YYYY — US month-first, not the day-first format your instinct reaches for in Nepal.

FieldMeaning
PIDMerchant code
MDPayment mode — always P
PRNYour unique reference number
AMTAmount
CRNCurrency — NPR
DTRequest date, MM/DD/YYYY
R1Remark 1 (required)
R2Remark 2 (optional)
RUReturn URL
DVThe HMAC-SHA512 signature
fonepay/redirect.ts
import { fonepay } from "./config"
import { sign } from "./signing"
 
export function buildPaymentUrl(input: {
  prn: string
  amount: number
  remarks1: string
  remarks2?: string
}): string {
  const today = new Date()
  const dt = `${String(today.getMonth() + 1).padStart(2, "0")}/${String(
    today.getDate()
  ).padStart(2, "0")}/${today.getFullYear()}` // MM/DD/YYYY
 
  const params = {
    PID: fonepay.merchantCode,
    MD: "P",
    PRN: input.prn,
    AMT: String(input.amount),
    CRN: "NPR",
    DT: dt,
    R1: input.remarks1,
    R2: input.remarks2 ?? "",
    RU: fonepay.returnUrl,
  }
 
  // Sign the RAW values in this exact order...
  const DV = sign(
    fonepay.secret,
    [
      params.PID,
      params.MD,
      params.PRN,
      params.AMT,
      params.CRN,
      params.DT,
      params.R1,
      params.R2,
      params.RU,
    ].join(",")
  )
 
  // ...then let URLSearchParams handle encoding. Query-string order is
  // irrelevant to Fonepay — only the SIGNING order above matters.
  const qs = new URLSearchParams({ ...params, DV })
  return `${fonepay.pgBaseUrl}/api/merchantRequest?${qs}`
}

Redirect the customer to that URL and they land on Fonepay's payment page.

2. Handle the return, then verify server-to-server

Fonepay's official flow redirects back to your RU with just PRN, BID, and UID. Those query params are not proof of payment — they only tell you which transaction to go verify. The truth comes from a server-to-server call whose DV signs PID,AMT,PRN,BID,UID, and the response is XML.

fonepay/verify.ts
import { fonepay } from "./config"
import { sign } from "./signing"
 
export type Verification = {
  success: boolean
  responseCode: string // "successful" on success
  amount: number
  uniqueId: string
}
 
export async function verifyTransaction(input: {
  prn: string
  bid: string // BID from the return query
  uid: string // UID from the return query
  amount: number // your EXPECTED amount, from your DB
}): Promise<Verification> {
  const { prn, bid, uid, amount } = input
 
  // EXACT order: PID, AMT, PRN, BID, UID
  const DV = sign(
    fonepay.secret,
    `${fonepay.merchantCode},${amount},${prn},${bid},${uid}`
  )
 
  const qs = new URLSearchParams({
    PRN: prn,
    PID: fonepay.merchantCode,
    BID: bid,
    AMT: String(amount),
    UID: uid,
    DV,
  })
 
  const res = await fetch(
    `${fonepay.pgBaseUrl}/api/merchantRequest/verificationMerchant?${qs}`,
    { signal: AbortSignal.timeout(30_000) }
  )
  if (!res.ok) throw new Error(`Verification failed: HTTP ${res.status}`)
 
  const xml = await res.text()
  const field = (name: string) =>
    xml.match(new RegExp(`<${name}>(.*?)</${name}>`, "s"))?.[1]?.trim() ?? ""
 
  return {
    success: field("success") === "true",
    responseCode: field("response_code"),
    amount: Number(field("amount")) || 0,
    uniqueId: field("uniqueId"),
  }
}

The XML you're parsing looks like this:

verificationMerchant response
<response>
  <amount>100</amount>
  <txnAmount>100</txnAmount>
  <bankCode>BANK</bankCode>
  <initiator>98XXXXXXXX</initiator>
  <message>payment success</message>
  <response_code>successful</response_code>
  <statusCode>1</statusCode>
  <success>true</success>
  <uniqueId>202406201234567</uniqueId>
</response>

The flat regex extraction keeps this dependency-free for Fonepay's simple, fixed response. For anything richer, reach for fast-xml-parser rather than hand-rolling.


Wiring it into NestJS

A thin controller over the helpers above, with OrdersService injected. Notice what it does not do: it never reads an amount from the request at verification time — the amount always comes from your database.

payments.controller.ts
import { Controller, Get, Param, Post, Query, Redirect } from "@nestjs/common"
 
import { createDynamicQr } from "./fonepay/qr"
import { buildPaymentUrl } from "./fonepay/redirect"
import { OrdersService } from "./orders/orders.service"
import { settlePayment } from "./payments/settle" // see "Getting it right in production"
 
@Controller("payments/fonepay")
export class PaymentsController {
  constructor(private readonly orders: OrdersService) {}
 
  // Start a QR payment.
  @Post("qr/:orderId")
  async startQr(@Param("orderId") orderId: string) {
    const order = await this.orders.findByIdOrThrow(orderId)
    const qr = await createDynamicQr({
      prn: order.prn,
      amount: order.expectedAmount, // from the DB, not the request
      remarks1: order.id,
      remarks2: "checkout",
    })
    return { qrMessage: qr.qrMessage, ws: qr.thirdpartyQrWebSocketUrl }
  }
 
  // Start a web-redirect payment.
  @Post("web/:orderId")
  async startWeb(@Param("orderId") orderId: string) {
    const order = await this.orders.findByIdOrThrow(orderId)
    return {
      url: buildPaymentUrl({
        prn: order.prn,
        amount: order.expectedAmount,
        remarks1: order.id,
      }),
    }
  }
 
  // Fonepay redirects the customer back here. @Redirect lets the return value
  // override the destination at runtime — no need to grab the raw res object.
  @Get("return")
  @Redirect()
  async onReturn(
    @Query("PRN") prn: string,
    @Query("BID") bid = "",
    @Query("UID") uid = ""
  ) {
    const status = await settlePayment(prn, { bid, uid })
    const base = process.env.CLIENT_URL
    return {
      url:
        status === "PAID"
          ? `${base}/payment/success`
          : `${base}/payment/failed`,
    }
  }
}

Register it in a module — OrdersService is provided here (or imported from its own module):

payments.module.ts
import { Module } from "@nestjs/common"
 
import { OrdersService } from "./orders/orders.service"
import { PaymentsController } from "./payments.controller"
 
@Module({
  controllers: [PaymentsController],
  providers: [OrdersService],
})
export class PaymentsModule {}

Getting it right in production

This is the part the copy-paste tutorials skip, and it's the only part that protects your revenue.

Never trust the client's amount

The browser can change any number you send it. So you derive the amount twice from your own data: once when you create the payment, and again when you verify it.

payments/create.ts
import { randomUUID } from "node:crypto"
 
export async function createOrderPayment(orderId: string) {
  const order = await db.orders.findByIdOrThrow(orderId)
 
  // Re-price from line items in YOUR database. Ignore any client total.
  const expectedAmount = order.lineItems.reduce(
    (sum, li) => sum + li.unitPrice * li.qty,
    0
  )
 
  const prn = randomUUID() // unique per attempt
  await db.orders.update(orderId, {
    prn,
    expectedAmount, // the persisted source of truth
    currency: "NPR",
    status: "PENDING",
  })
 
  return { prn, expectedAmount }
}

Fulfil exactly once

Fonepay can hit your return URL more than once, the customer can refresh it, and your QR poller can fire at the same time. Without a guard, that's a double-shipped order. The fix is a state machine plus one atomic transition: only the first caller that flips PENDING wins.

payments/settle.ts
import { verifyTransaction } from "../fonepay/verify"
 
export async function settlePayment(
  prn: string,
  ref: { bid: string; uid: string }
): Promise<"PAID" | "FAILED" | "PENDING"> {
  const order = await db.orders.findByPrnOrThrow(prn)
 
  // Already decided? No-op. This is what makes a refresh / retry safe.
  if (order.status !== "PENDING") return order.status
 
  // The gateway is the source of truth — not the redirect query string.
  const v = await verifyTransaction({
    prn,
    bid: ref.bid,
    uid: ref.uid,
    amount: order.expectedAmount,
  })
 
  if (!v.success || v.responseCode !== "successful") {
    await db.orders.updateWhere(
      { id: order.id, status: "PENDING" },
      { status: "FAILED" }
    )
    return "FAILED"
  }
 
  // Re-check the paid amount against what we stored. Reject under- AND overpay.
  if (v.amount !== order.expectedAmount) {
    throw new Error(
      `Amount mismatch: paid ${v.amount}, expected ${order.expectedAmount}`
    )
  }
 
  // Atomic: flip PENDING -> PAID only if still PENDING. Exactly one winner.
  const flipped = await db.orders.updateWhere(
    { id: order.id, status: "PENDING" },
    { status: "PAID", gatewayRef: v.uniqueId }
  )
  if (flipped.rowCount === 1) await fulfilOrder(order.id) // runs once, ever
 
  return "PAID"
}

Reconcile the stragglers

Networks drop. A payment can succeed at the bank while your callback never arrives, leaving an order stuck PENDING. Run a cron that re-checks pending orders against getQrStatus (QR) or verifyTransaction (redirect) and resolves them. Without it, you'll quietly lose real, paid orders.

The rest of the checklist

  • Secrets live in env or a secret manager — never in client code, never committed.
  • Your return URL must be public HTTPS. For local dev, tunnel it: npx ngrok http 3000 or cloudflared.
  • fetch doesn't throw on 4xx/5xx. Always check res.ok (every snippet above does). Use AbortSignal.timeout() so a stalled gateway can't hang your request.
  • Compare signatures with safeHexEqual, never with ===.

Testing in the sandbox

Point your config at the dev hosts (dev-clientapi.fonepay.com, uat-new-merchant-api.fonepay.com) and use the credentials from your onboarding packet. Fonepay's public Spring sample ships working dev keys — merchant code NBQM — handy for a first redirect smoke test. Remember the sandbox only offers a single test bank; the full bank/wallet list only appears with live credentials.


Troubleshooting

Nearly every Fonepay bug is the signature. When Fonepay rejects a request, check, in order:

  • Field order. It must match the signing string exactly — amount,prn,merchantCode,... for QR, PID,MD,PRN,... for redirect. One swapped field, silent failure.
  • Encode-after-sign. Hash the raw values; URL-encode only for transport. Signing the encoded string is the classic mistake.
  • Date format. Redirect DT is MM/DD/YYYY. June 20th is 06/20/2026, not 20/06/2026.
  • Hex case. If you must string-compare, do it case-insensitively — but really, just use safeHexEqual.
  • Wrong host. QR lives on merchantapi, redirect on clientapi. They are not interchangeable.

Conclusion

The Fonepay integration is genuinely small — sign a string, make a call, verify the result. What separates a demo from something you'd put real money through is everything around that core: deriving amounts from your own database, treating the gateway's server-to-server response as the only authority, and making fulfilment idempotent so no refresh or retry can charge or ship twice.

Build the signing primitive once, keep the secret server-side, verify everything, and Fonepay becomes the boring, reliable part of checkout — which is exactly what you want a payment rail to be.

Running a Go backend? The same integration, ported to the standard library, lives in Integrating Fonepay with Go.

Command Palette

Search for a command to run...