KHQR Pay API Documentation

Generate KHQR codes, check Bakong payment status, and manage API keys — all through a simple REST API.

Base URL
https://apikhqr.kesor.cam/api/v1

Getting Started

  1. Create an account — your API key is generated automatically.
  2. Save your Merchant ID and Merchant Name in the dashboard.
  3. Call POST /qr to generate a KHQR.
  4. Poll POST /transaction/check to see when it's paid.

Authentication

All API requests require a Bearer token in the Authorization header:

Authorization: Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxx

Get your key from the dashboard — it's generated automatically on signup. All requests and responses use Content-Type: application/json.

Client Website Integration

For a real customer checkout, keep your KHQR API key on your own server. Your website calls your backend, then your backend calls KHQR Pay. This prevents visitors from copying your live API key from browser devtools.

Frontend checkout button
<button id="pay">Pay with KHQR</button>
<pre id="result"></pre>

<script>
document.getElementById("pay").addEventListener("click", async () => {
  const res = await fetch("/api/create-khqr", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      amount: 5,
      currency: "USD",
      order_id: "ORDER-" + Date.now()
    })
  });

  const data = await res.json();
  document.getElementById("result").textContent = JSON.stringify(data, null, 2);

  // Render data.qr with any QR library, then poll your backend with data.md5.
});
</script>
Your backend route
// Node.js / Express example
app.post("/api/create-khqr", async (req, res) => {
  const r = await fetch("https://apikhqr.kesor.cam/api/v1/qr", {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + process.env.KHQR_API_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(req.body)
  });

  res.status(r.status).json(await r.json());
});

app.post("/api/check-khqr", async (req, res) => {
  const r = await fetch("https://apikhqr.kesor.cam/api/v1/transaction/check", {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + process.env.KHQR_API_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ md5: req.body.md5 })
  });

  res.status(r.status).json(await r.json());
});

Direct browser calls to https://apikhqr.kesor.cam/api/v1 are CORS-enabled for testing, but production websites should proxy through a backend so the API key stays private.

Error Format & Codes

Every error response has this shape. Branch on HTTP status, never on the human message.

{
  "error": "bad_request",
  "message": "amount must be a positive number"
}
HTTPerrorMeaning
400invalid_inputMissing or invalid parameter
400merchant_not_configuredSet merchant info in dashboard first
401unauthorizedMissing or invalid API key
403forbiddenAPI key was revoked
404not_foundResource (e.g. transaction) does not exist
409conflictorder_id already used (idempotency replay mismatch)
429rate_limit_exceededDaily plan limit reached — upgrade to continue
500internal_errorServer-side failure

Generate QR

POST/api/v1/qr

Generate a Bakong KHQR for a payment amount. Returns the QR string and MD5 hash.

curl -X POST https://apikhqr.kesor.cam/api/v1/qr \
  -H "Authorization: Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"amount": 5.00, "currency": "USD", "bill_number": "INV-001"}'
REQUEST BODY
{
  "amount": 5.00,            // optional — 0 or omitted = static reusable QR
  "currency": "USD",         // "USD" | "KHR" — defaults to your saved currency
  "bill_number": "INV-001",  // optional, max 25 chars
  "store_label": "Shop",     // optional, max 25 chars
  "terminal_label": "T1",    // optional, max 25 chars
  "expires_in_seconds": 900, // optional, 30..86400 (default 900 = 15 min)
  "order_id": "ORDER-1042"   // optional — idempotency key, max 64 chars
}
RESPONSE 200
{
  "qr": "00020101021229...",
  "md5": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "currency": "USD",
  "amount": 5.00
}

Check Payment

POST/api/v1/transaction/check

Verify whether a transaction has been paid via Bakong. Poll every 3 seconds while showing the QR.

curl -X POST https://apikhqr.kesor.cam/api/v1/transaction/check \
  -H "Authorization: Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"md5": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"}'
RESPONSE 200
{
  "md5": "a1b2c3d4...",
  "status": "paid",            // "pending" | "paid"
  "bakong_response": { ... }   // raw NBC response
}

Lookup by Order ID

POST/api/v1/transaction/by-order

Query a transaction's status by your own order_id. Use this when you don't have the md5 handy (e.g. inside your admin panel).

curl -X POST https://apikhqr.kesor.cam/api/v1/transaction/by-order \
  -H "Authorization: Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ORDER-1042"}'
RESPONSE 200
{
  "order_id": "ORDER-1042",
  "md5": "a1b2c3...",
  "status": "paid",            // "pending" | "paid" | "expired"
  "amount": 5.00,
  "currency": "USD",
  "bill_number": "INV-001",
  "paid_at": "2026-05-13T14:00:00.000Z",
  "expires_at": "2026-05-13T14:15:00.000Z",
  "created_at": "2026-05-13T14:00:00.000Z"
}

Account Info

GET/api/v1/me

Returns the account that owns the current API key.

curl https://apikhqr.kesor.cam/api/v1/me \
  -H "Authorization: Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxx"
{
  "user_id": "uuid",
  "merchant": {
    "bakong_account_id": "name@aclb",
    "merchant_name": "MY SHOP",
    "merchant_city": "Phnom Penh",
    "currency": "USD"
  }
}

Idempotency

Pass an order_id when generating a QR. If the same order_id is sent again, the existing QR + md5 are returned (with idempotent_replay: true) instead of generating a new one — safe for client retries.

Rate Limits

Each API key is limited per minute, in addition to your plan's daily quota:

  • POST /qr — 60 req/min
  • POST /transaction/check — 120 req/min
  • POST /transaction/by-order — 120 req/min

When exceeded you get 429 with Retry-After + X-RateLimit-Limit / X-RateLimit-Remaining headers.

Webhooks

Add an endpoint URL in the dashboard. We POST a JSON event whenever a transaction transitions to paid. Failed deliveries retry with exponential backoff (up to 5 attempts).

POST https://your.app/webhook
X-KHQR-Signature: t=1715600000,v1=<hmac_sha256_hex>
X-KHQR-Timestamp: 1715600000
X-KHQR-Delivery-Id: <uuid>
X-KHQR-Event: transaction.paid
Content-Type: application/json

{
  "event": "transaction.paid",
  "md5": "a1b2c3...",
  "amount": 5.00,
  "currency": "USD",
  "bill_number": "INV-001",
  "paid_at": "2026-05-13T14:00:00.000Z",
  "transaction_id": "uuid",
  "external_order_id": "ORDER-1042"
}

Verifying signatures (Node.js):

import crypto from "crypto";

function verify(rawBody, headers, secret) {
  const sigHeader = headers["x-khqr-signature"]; // "t=...,v1=..."
  const ts = Number(headers["x-khqr-timestamp"]);
  if (!sigHeader || !ts) return false;

  // 1. Reject stale requests (replay protection)
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false;

  // 2. Recompute HMAC over "<ts>.<raw_body>" using the RAW body (do not re-serialize JSON)
  const v1 = sigHeader.split(",").find(p => p.startsWith("v1="))?.slice(3);
  const expected = crypto.createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`).digest("hex");

  // 3. Constant-time compare
  if (!v1 || v1.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));

  // 4. Also dedupe by X-KHQR-Delivery-Id in your DB to guard against replays.
}

Bakong KHQR

KHQR Pay generates payment QR codes that conform to the National Bank of Cambodia's KHQR EMV standard, scannable by ABA, Wing, ACLEDA, and any Bakong-enabled bank app. Configure your Merchant ID (Bakong account ID, e.g. name@aclb) and Merchant Name in the dashboard once — the API uses them automatically.

Full Checkout Flow

  1. Customer clicks Pay on your store.
  2. Your server calls POST /qr with the amount.
  3. Render the returned qr string as a QR image.
  4. Poll POST /transaction/check with the md5 every ~3s.
  5. When status is paid, fulfill the order.
Node.js example
const r = await fetch("https://apikhqr.kesor.cam/api/v1/qr", {
  method: "POST",
  headers: {
    "Authorization": "Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxx",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount: 25.00, currency: "USD", bill_number: "ORDER-1042" }),
}).then(r => r.json());

console.log(r.qr, r.md5);

// Poll for payment
const poll = async () => {
  const s = await fetch("https://apikhqr.kesor.cam/api/v1/transaction/check", {
    method: "POST",
    headers: { "Authorization": "Bearer khqr_live_xxx", "Content-Type": "application/json" },
    body: JSON.stringify({ md5: r.md5 }),
  }).then(r => r.json());
  if (s.status === "paid") return console.log("PAID");
  setTimeout(poll, 3000);
};
poll();

Ready to integrate?

Create a free account and start accepting KHQR payments in minutes.