Generating and Displaying a KHQR Code

Table of Contents

  1. Why this whole step is offline
  2. Generate the KHQR string and MD5 hash
  3. Render it as a scannable image
  4. Display it on the frontend
  5. Beginner Pitfalls

1. Why this whole step is offline

Unlike a payment gateway that signs and submits a request server-side (e.g. ABA PayWay's Purchase API), generating a KHQR code involves no Bakong API call at all. The QR payload itself just encodes who gets paid, how much, and until when — there is nothing for a server to "create" on Bakong's side first. This example still builds the KHQR server-side, but only for architectural consistency (and to keep expirationTimestamp server-controlled) — not because anything here needs protecting the way a signed request would.

npm install bakong-khqr qrcode

bakong-khqr is NBC's official KHQR SDK — the one used in the KHQR SDK Document — and builds the KHQR string; qrcode turns any string into a scannable image.

2. Generate the KHQR string and MD5 hash

const { BakongKHQR, khqrData, IndividualInfo } = require('bakong-khqr');

const DEMO_AMOUNT = 5000; // 5,000 KHR, hardcoded for this teaching example
const EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes
const expiresAt = Date.now() + EXPIRATION_MS;

const individualInfo = new IndividualInfo(
  process.env.BAKONG_ACCOUNT_ID,
  process.env.BAKONG_MERCHANT_NAME,
  process.env.BAKONG_MERCHANT_CITY || 'Phnom Penh',
  {
    currency: process.env.BAKONG_CURRENCY === 'USD'
      ? khqrData.currency.usd
      : khqrData.currency.khr,
    amount: DEMO_AMOUNT,
    expirationTimestamp: expiresAt,
  },
);

const result = new BakongKHQR().generateIndividual(individualInfo);

if (result.status.code !== 0) {
  return res.status(500).json({ error: result.status.message });
}

// result.data.qr  -> the raw KHQR string (EMVCo format)
// result.data.md5 -> MD5 hash of that string, used in Part 3

new BakongKHQR() takes no arguments — generating a KHQR needs no access token. The IndividualInfo constructor takes the Bakong Account ID, merchant name, and city in that order, then an object of optional fields.

Generation doesn't throw on bad input — it returns status.code: 1 with a message (and data: null), so always check status.code before using result.data.

expirationTimestamp (in milliseconds, like Date.now()) is required whenever amount is set (a "dynamic" KHQR) — leave it out and you get "Expiration timestamp is required for dynamic KHQR". A KHQR with no amount is "static" (reusable, payer enters the amount) and has no such requirement.

KHR amounts are whole numbers (no decimals); USD amounts use decimals. Passing the wrong shape for the selected currency produces a malformed QR.

3. Render it as a scannable image

result.data.qr is plain text — a phone camera can't scan text directly. Convert it to an image before it's any use to a payer:

const QRCode = require('qrcode');

const qrImage = await QRCode.toDataURL(result.data.qr); // data:image/png;base64,...

Unlike PayWay's Purchase API, which returns a ready-made QR PNG directly in its response, Bakong only ever gives you the raw string — rendering it is entirely on you.

Return everything the frontend needs:

res.json({
  qrImage,               // data URI, ready for an <img> tag
  md5: result.data.md5,  // needed to check payment status in Part 3
  amount: DEMO_AMOUNT,
  currency: process.env.BAKONG_CURRENCY || 'KHR',
  expiresAt,
});

4. Display it on the frontend

<div id="result">Generating KHQR...</div>

<script>
  async function createPayment() {
    const resultEl = document.getElementById('result');
    const res = await fetch('/create-payment', { method: 'POST' });
    const data = await res.json();

    if (data.error) {
      resultEl.textContent = `Failed to generate KHQR: ${data.error}`;
      return;
    }

    resultEl.innerHTML = `
      <p>Scan with any Bakong-participating banking app:</p>
      <img src="${data.qrImage}" alt="Bakong KHQR code" width="250" height="250">
      <p>${data.amount.toLocaleString()} ${data.currency}</p>
      <p id="status">Waiting for payment...</p>
    `;

    // pollPaymentStatus(data.md5, data.expiresAt) — see Part 3
  }

  createPayment();
</script>

Generation happens the moment the page loads, with no button click needed — unlike a gateway checkout, there's no "create a transaction" round trip here, just an offline string turned into an image.

5. Beginner Pitfalls

  • Forgetting expirationTimestamp on a dynamic QR. Required the moment amount is set — omitting it fails generation.
  • Not checking result.status.code. A failed generation returns an error status instead of throwing, so reading result.data.qr without checking crashes on null.
  • Treating the raw qr string as something you can display directly. It needs to go through a QR-image library first — see Section 3.
  • Assuming KHQR generation needs network access or an access token. It doesn't — only the status-check step in Part 3 does.
  • Mixing up KHR and USD amount formatting. KHR is whole numbers, USD uses decimals — passing the wrong shape produces a malformed QR.

Buy Me a Coffee

Found this tutorial helpful? Consider buying me a coffee to support more free content!