Checking Payment Status

ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។

Table of Contents

  1. Why you need this step
  2. The check-transaction call
  3. Polling from the frontend
  4. Testing checklist
  5. Beginner Pitfalls

1. Why you need this step

Everything in Part 2 happens offline — generating a KHQR and rendering it as an image tells you nothing about whether anyone actually paid it. This is the one step in the whole flow that talks to Bakong's real infrastructure, using the MD5 hash from Part 2 to ask "has this specific QR been paid yet?"

2. The check-transaction call

Confirmed against the Bakong Open API Document and NBC's QR Payment Integration guide:

router.post('/check-payment-status', async (req, res) => {
  const { md5 } = req.body;

  const bakongRes = await fetch(`${process.env.BAKONG_API_BASE_URL}/check_transaction_by_md5`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.BAKONG_ACCESS_TOKEN}`,
    },
    body: JSON.stringify({ md5 }),
  });

  if (bakongRes.status === 401) {
    return res.status(500).json({ error: 'Bakong access token is invalid or expired' });
  }

  if (bakongRes.status === 404) {
    return res.json({ status: 'pending' });
  }

  const data = await bakongRes.json();

  if (data.responseCode === 0 && data.data) {
    return res.json({ status: 'paid', transaction: data.data });
  }

  if (data.responseMessage === 'Transaction failed.') {
    return res.json({ status: 'failed' });
  }

  res.json({ status: 'pending' });
});

This series runs against Bakong's production Open API, so set the base URL in your backend's .env:

BAKONG_API_BASE_URL=https://api-bakong.nbc.gov.kh/v1
BAKONG_ACCESS_TOKEN=your-access-token

Bakong answers in one of three ways:

Success — paid:

{
  "responseCode": 0,
  "responseMessage": "Getting transaction successfully.",
  "data": {
    "hash": "8465d722d7d5...",
    "fromAccountId": "customer@bank",
    "toAccountId": "you@bank",
    "currency": "USD",
    "amount": 1.0,
    "description": "..."
  }
}

Failed — the payment went through the customer's app but failed:

{ "data": null, "errorCode": 1, "responseCode": 1, "responseMessage": "Transaction failed." }

Not found — nobody has paid this QR yet:

{ "data": null, "errorCode": 1, "responseCode": 1, "responseMessage": "Transaction could not be found. Please check and try again." }

Failed and not found share the same responseCode and errorCode (both 1), so the only way to tell them apart is responseMessage — which is why the code above compares that string. Treat anything that isn't clearly success or failed as "still pending", so an unexpected message just means you keep checking.

The success response also tells you who paid how much to whom. Before marking a real order paid, it's worth checking that data.amount, data.currency, and data.toAccountId match the order you created.

Getting and renewing the access token

  1. Register at api-bakong.nbc.gov.kh/register with your organization, project name, and email.
  2. Verify your email. NBC emails you a confirmation code; enter it to verify.
  3. Wait for the token email. After verification, the token arrives from no.reply@nbc.gov.kh, within 24 hours. Paste it into .env as BAKONG_ACCESS_TOKEN.
  4. Note the expiry date. Tokens expire 90 days after issue; the exact Expire Date is in the email. NBC emails you a reminder 3 days before it expires.
  5. Renew when it expires. On the register page, click Token expired? Renew, enter the same email, and a new token (with a new expiry date) is emailed to you. Swap it into .env and restart your backend.

When the 3-day reminder email arrives, renew straight away. Don't wait for the token to expire: an expired token makes every status check fail, so paid orders will look unpaid.

The token is also limited to 100 requests per day (the limit resets daily), which matters for polling — see the note in section 3.

Whichever access token you use here must only ever live on your backend/server, never in browser JavaScript, and never committed to a git repo — the Account ID from Part 1 is fine to be public; this token is not.

3. Polling from the frontend

async function pollPaymentStatus(md5, expiresAt) {
  const statusEl = document.getElementById('status');
  const POLL_INTERVAL_MS = 3000;

  while (Date.now() < expiresAt) {
    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));

    const res = await fetch('/check-payment-status', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ md5 }),
    });
    const data = await res.json();

    if (data.status === 'paid') {
      statusEl.textContent = 'Payment received!';
      return;
    }

    if (data.status === 'failed') {
      statusEl.textContent = 'Payment failed. Refresh the page to try again.';
      return;
    }
  }

  statusEl.textContent = 'This KHQR has expired. Refresh the page for a new one.';
}

Kick this off right after displaying the QR image (Part 2), and stop as soon as you get a paid or failed result or the QR's own expirationTimestamp passes — a dynamic KHQR is only valid until then regardless of how long you keep polling.

Mind the 100-requests-per-day limit. Every poll here is one Bakong API call. At one poll every 3 seconds, a single QR left open for 5 minutes uses all 100 requests for the day. Fine for trying things out; for anything with real traffic, poll less often and keep QR expiry short.

4. Testing checklist

  • Loading the page generates a KHQR image with no errors (this needs no network access, so it should work even with a placeholder access token).
  • The image is actually scannable by a QR-reading app, not just visually present.
  • /check-payment-status returns { status: 'pending' } rather than crashing, given a valid but unpaid md5.
  • The page correctly shows an "expired" state once expirationTimestamp passes without payment.
  • A small real payment (e.g. ៛100) is detected as paid by /check-payment-status.

5. Beginner Pitfalls

  • Putting the Bakong access token in frontend JavaScript. Like any API credential, it must stay server-side.
  • Polling forever. Stop once you get a paid result or the QR expires — don't poll past expirationTimestamp. With a 100-requests-per-day limit, runaway polling can lock you out until the next day.
  • Letting the token expire unnoticed. Bakong starts answering 401 and no payment can be confirmed. Tokens last 90 days — renew when NBC's 3-day reminder email arrives.
  • Treating "failed" like "not found". Both come back with responseCode: 1; only responseMessage differs. If you treat them the same, a failed payment leaves the customer waiting until the QR expires.
  • Trying to check a static KHQR by MD5. check_transaction_by_md5 only works for dynamic KHQRs (ones with an amount), like the one this series builds.
  • Assuming a 404 means an error. A 404 from check_transaction_by_md5 is an expected "not paid yet" response, not a failure to handle specially.
  • Forgetting that test payments are real money. This runs against production, so every test payment actually moves funds — keep test amounts tiny.

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!