KHQR Pay API Documentation
Generate KHQR codes, check Bakong payment status, and manage API keys — all through a simple REST API.
Getting Started
- Create an account — your API key is generated automatically.
- Save your Merchant ID and Merchant Name in the dashboard.
- Call
POST /qrto generate a KHQR. - Poll
POST /transaction/checkto see when it's paid.
Authentication
All API requests require a Bearer token in the Authorization header:
Authorization: Bearer khqr_live_xxxxxxxxxxxxxxxxxxxxxxxxGet 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.
<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>// 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"
}| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_input | Missing or invalid parameter |
| 400 | merchant_not_configured | Set merchant info in dashboard first |
| 401 | unauthorized | Missing or invalid API key |
| 403 | forbidden | API key was revoked |
| 404 | not_found | Resource (e.g. transaction) does not exist |
| 409 | conflict | order_id already used (idempotency replay mismatch) |
| 429 | rate_limit_exceeded | Daily plan limit reached — upgrade to continue |
| 500 | internal_error | Server-side failure |
Generate 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"}'{
"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
}{
"qr": "00020101021229...",
"md5": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"currency": "USD",
"amount": 5.00
}Check Payment
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"}'{
"md5": "a1b2c3d4...",
"status": "paid", // "pending" | "paid"
"bakong_response": { ... } // raw NBC response
}Lookup by Order ID
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"}'{
"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
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/minPOST /transaction/check— 120 req/minPOST /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
- Customer clicks Pay on your store.
- Your server calls
POST /qrwith the amount. - Render the returned
qrstring as a QR image. - Poll
POST /transaction/checkwith themd5every ~3s. - When
statusispaid, fulfill the order.
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.