The one thing that will catch you out
A successful ABA payment is approved. A successful Bakong payment is paid. If your code only checks for approved, Bakong payments will silently look unpaid. Check for both.
Reference
The Tola Saint API lets you accept KHQR payments and track their status. It's a small, predictable REST API — all requests use HTTPS and all responses are JSON.
Base URL
https://api.tolasaint.com
Amounts are always strings, never numbers — that keeps floating point out of money.
| Currency | Format | Examples |
|---|---|---|
| USD | decimal, max 2 dp | "1.00", "0.50", "12" — a third decimal place is rejected. |
| KHR | whole number | "4000" — any decimal point is rejected. |
Payments can run over two rails: bakong (KHQR issued by the National Bank of Cambodia) or aba (an ABA PayWay hosted merchant link). The endpoints, request shape and webhooks are identical. What differs is the statuses each one can report.
The one thing that will catch you out
A successful ABA payment is approved. A successful Bakong payment is paid. If your code only checks for approved, Bakong payments will silently look unpaid. Check for both.
// treat either success value as paid if (res.status === "approved" || res.status === "paid") { fulfilOrder(res.id) }
| Aspect | Same? | Detail |
|---|---|---|
| Setup | differs | ABA needs a hosted PayWay link per currency. Bakong needs one account ID, like your_name@aclb, which covers both currencies. |
| Creating a QR | differs | ABA is generated by the bank, so create can fail on an upstream outage. Bakong is built locally, so it cannot. |
| Statuses | differs | ABA reports pending, scanned, processing, then approved or failed. Bakong reports pending, then paid — it has no intermediate reporting to expose. |
| Success value | differs | approved on ABA, paid on Bakong. Treat both as success, or read the provider field and branch. |
| Expiry | same | Both expire at expires_at and both report expired when the window closes. |
| Everything else | same | Same endpoints, same request shape, same webhooks, same QR link. Only the values above change. |
Each rail has its own endpoint, so the simplest approach is to call the one you want: /v1/bakong for Bakong, /v1/payment for ABA. Either way the answer comes back in the provider field.
| Call | Rail | Detail |
|---|---|---|
| POST /v1/bakong | bakong | Always Bakong. The rail is fixed by the route, so there is nothing to configure per request and no ambiguity about which statuses come back. |
| POST /v1/payment | aba | Uses your ABA link for the requested currency. Adding a Bakong account ID does not change this, so an existing integration keeps the rail and statuses it already has. |
| POST /v1/payment, no ABA link | bakong | If no ABA link covers the requested currency but a Bakong account ID is set, Bakong is used rather than returning an error. Read the provider field to be sure. |
| provider in the body | wins | On /v1/payment an explicit "aba" or "bakong" overrides the above. It still fails with merchant_not_configured if that rail is not set up. |
Authenticate every request with your secret key in the x-api-key header.
| Header | Type | Description |
|---|---|---|
| x-api-key required | string | Your secret key from the dashboard. Required on every endpoint except the public QR link and the health check. |
curl https://api.tolasaint.com/v1/payment/status?id=AbC123 \
-H "x-api-key: sk_live_your_key_here"GET /health needs no key and is exempt from rate limiting — point an uptime monitor at it. It answers { "status": "ok" } when the service is up.
curl https://api.tolasaint.com/health
Every error uses the same shape:
{ "error": "bad_request", "message": "Invalid amount" }| Field | Type | Description |
|---|---|---|
| error | string | Stable machine-readable code. Branch on this, never on the message. |
| message | string | Human-readable explanation, safe to log. The wording may change. |
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Invalid body or query — a malformed amount, a missing currency, an unparseable filter. |
| 400 | merchant_not_configured | The rail for this payment is not set up. On ABA that means no link for the currency you asked for — each link is fixed to one currency, so USD and KHR are set separately. On Bakong it means no account ID. Both live under Merchant Settings. |
| 400 | merchant_link_currency_mismatch | The link configured for that currency turned out to be for the other one. Nothing was charged; fix the link under Merchant Settings. |
| 401 | unauthorized | Missing or invalid x-api-key. |
| 404 | not_found | Unknown payment id, or one belonging to another merchant. The two are deliberately indistinguishable. |
| 400 | wrong_provider | From /v1/bakong/status when the payment was created on ABA. Use /v1/payment/status for it. Only ever returned for your own payments. |
| 400 / 415 | request_error | The request itself was malformed before it reached a handler — unparseable JSON, or a content-type the endpoint does not accept. |
| 429 | request_error | Over 120 requests in a minute for this key. Wait for retry-after, then retry. |
| 502 | upstream_error | ABA refused or timed out. Safe to retry. |
| 503 | provider_unavailable | You asked for Bakong but this server has the rail switched off. Nothing is wrong with your settings — send "provider": "aba", or omit provider and an available ABA link is used automatically. |
| 502 | qr_verification_failed | The provider returned a QR that failed validation, so it was discarded instead of handed to you. |
| 500 | internal_error | Unexpected server fault. Never carries internal detail. |
120 requests per minute, counted per API key rather than per IP — one busy key can't spend another's budget. Request bodies are capped at 64 KB. Every response carries the current budget:
| Header | Type | Description |
|---|---|---|
| x-ratelimit-limit | number | Requests allowed in the window. Currently 120. |
| x-ratelimit-remaining | number | Requests left in the current window. |
| x-ratelimit-reset | number | Seconds until the window resets. |
| retry-after | number | Seconds to wait. Sent only on a 429. |
Create a KHQR payment. Returns a QR string, a hosted QR link, and an expiry.
| Parameter | Type | Description |
|---|---|---|
| amount required | string | Positive decimal as a string. USD allows at most two decimal places; KHR must be a whole number. |
| currency required | "USD" | "KHR" | Currency of the payment. On ABA this selects which of your two merchant links is used, so the matching one has to be configured. Bakong covers both with one account ID. |
| provider | "aba" | "bakong" | Force a rail. Omit it and the server picks: Bakong when you have a Bakong account ID configured, otherwise ABA. |
| reference | string | Your own order or invoice number, up to 128 characters. Echoed back and included in webhooks. Not required to be unique. |
| metadata | object | Free-form key/value data kept with the payment, up to 4 KB once serialised. Never shown to the payer. |
curl -X POST https://api.tolasaint.com/v1/payment \
-H "x-api-key: sk_live_..." \
-H "content-type: application/json" \
-d '{"amount":"1.00","currency":"USD","reference":"order-778"}'| Field | Type | Description |
|---|---|---|
| id | string | Payment id, 12 characters. Use it to check status. |
| status | string | Always "pending" on creation. |
| provider | "aba" | "bakong" | The rail that issued this QR. Read it to know which statuses to expect — the two rails do not report the same set. |
| amount | string | The amount you sent, unchanged. |
| currency | string | The currency you sent. |
| qr_link | string | Hosted QR image. Embeddable and safe to show the payer; no API key needed. |
| qr_string | string | Raw KHQR payload, if you would rather render the code yourself. Verified against the amount and currency you asked for before it is returned. |
| expires_at | string | ISO 8601 expiry. Taken from ABA when it supplies one, otherwise from the server TTL. Bakong QRs always use the server TTL. |
| reference | string | Present only when you sent one. |
// 201 Created { "id": "AbC123xyz789", "status": "pending", "provider": "bakong", "amount": "1.00", "currency": "USD", "qr_link": "https://api.tolasaint.com/qr/uQH8rR0AIv0djTnPLJ3N", "qr_string": "00020101021230510016abaakhppxxx...", "expires_at": "2026-01-01T00:03:00.000Z", "reference": "order-778" }
Check a transaction by id.
| Parameter | Type | Description |
|---|---|---|
| id required | string | The payment id returned by create. Only your own payments are visible. |
curl "https://api.tolasaint.com/v1/payment/status?id=AbC123xyz789" \
-H "x-api-key: sk_live_..."| Field | Type | Description |
|---|---|---|
| id | string | The payment id you queried. |
| amount | string | Amount of the payment. |
| currency | string | Currency of the payment. |
| status | string | Current status; see the values below. |
| provider | "aba" | "bakong" | The rail this payment lives on, so you know which statuses can appear. |
// 200 OK { "id": "AbC123xyz789", "amount": "1.00", "currency": "USD", "status": "approved", "provider": "aba" }
| Status | Rail | Meaning |
|---|---|---|
| pending | both | Created, not paid yet. Not terminal, and no webhook is sent for it. Every payment starts here. |
| scanned | aba | The payer opened the QR in their banking app. Not terminal. |
| processing | aba | The bank is settling the transfer. Not terminal. |
| approved | aba | Paid. ABA's success state, and terminal — the money is yours. |
| paid | bakong | Paid. Bakong's success state, and terminal. Means exactly what approved means on ABA. |
| failed | aba | The bank rejected or the payer abandoned the transfer. Terminal. |
| expired | both | The window in expires_at passed without payment. Terminal. |
Terminal states never change again, so you can stop polling once you see one. Which statuses you'll actually see depends on the rail — see Payment rails.
// 200 OK — note "paid", not "approved" { "id": "AbC123xyz789", "amount": "1.00", "currency": "USD", "status": "paid", "provider": "bakong" }
Page through your payments, newest first. Every parameter is optional, so a bare call returns the 20 most recent.
| Parameter | Type | Description |
|---|---|---|
| limit | number | How many to return, 1 to 100. Defaults to 20. |
| offset | number | How many to skip, for paging through the result. Defaults to 0. |
| status | string | Return only this status: pending, scanned, processing, approved, paid, failed or expired. Remember ABA succeeds as approved and Bakong as paid. |
| currency | "USD" | "KHR" | Return only this currency. |
| q | string | Case-insensitive search across payment id and reference, up to 128 characters. |
curl "https://api.tolasaint.com/v1/payments?limit=20&status=approved" \
-H "x-api-key: sk_live_..."| Field | Type | Description |
|---|---|---|
| data | array | The matching payments, newest first. Fields are listed below. |
| total | number | How many payments match the filters in total, ignoring limit and offset. |
| limit | number | The limit that was applied. |
| offset | number | The offset that was applied. |
| Field | Type | Description |
|---|---|---|
| id | string | Payment id. |
| status | string | Current status. |
| provider | "aba" | "bakong" | Which rail issued the QR. |
| amount | string | Amount of the payment. |
| currency | string | Currency of the payment. |
| reference | string | null | Your reference, or null. |
| created_at | string | ISO 8601 time the payment was created. |
| updated_at | string | ISO 8601 time the status last changed. |
| paid_at | string | null | ISO 8601 time the money arrived — when the status became approved or paid — or null if it has not. |
| expires_at | string | ISO 8601 expiry of the QR. |
// 200 OK { "data": [ { "id": "AbC123xyz789", "status": "approved", "provider": "aba", "amount": "1.00", "currency": "USD", "reference": "order-778", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:01:12.000Z", "paid_at": "2026-01-01T00:01:12.000Z", "expires_at": "2026-01-01T00:03:00.000Z" } ], "total": 1, "limit": 20, "offset": 0 }
The qr_link renders the KHQR as a scannable image. It's a public capability URL protected by an unguessable token — embed it directly.
| Parameter | Type | Description |
|---|---|---|
| token required | string | The unguessable token embedded in qr_link. It is the only credential on this route, so treat the link as a secret you hand to one payer. |
<img src="https://api.tolasaint.com/qr/uQH8rR0AIv0djTnPLJ3N" alt="KHQR" /> An <img> or Markdown embed gets a bare 300×300 SVG with a transparent background. Opening the link in a browser tab instead gets a centred page around the same image. Neither is cached.
| Payment state | Response | Why |
|---|---|---|
| pending, scanned, processing | 200 | Still payable, so the image is served. |
| past expires_at | 410 Gone | Refused even if the stored status still reads pending — expiry is judged on the clock, not on whether anything has polled yet. |
| approved or paid | 410 Gone | Already settled. Serving it again would invite a second payment for one order. |
| failed or expired | 410 Gone | Terminal; the QR cannot be paid. |
| unknown token | 404 | Kept distinct from 410 on purpose: a 404 never confirms that a token existed in the first place. |
If you cache or re-render the QR yourself
Don't serve a stored copy after expires_at. A payer who scans a dead QR can still send the money at the bank, but the payment is already terminal here — so you'd get no webhook and no record of the sale. Create a fresh payment instead.
Create a KHQR payment on Bakong. Same job as /v1/payment, except the rail is fixed by the route — so there is no provider field, and you know exactly which statuses to expect.
The QR is built on our server rather than fetched from a bank, so this call makes no upstream request and cannot fail because Bakong is having a bad day. One Bakong account ID covers both USD and KHR.
| Parameter | Type | Description |
|---|---|---|
| amount required | string | Positive decimal as a string. USD allows at most two decimal places; KHR must be a whole number. |
| currency required | "USD" | "KHR" | Currency of the payment. One Bakong account ID covers both, so there is nothing extra to configure for either. |
| reference | string | Your own order or invoice number, up to 128 characters. Echoed back, included in webhooks, and written into the QR as the bill number. Not required to be unique. |
| metadata | object | Free-form key/value data kept with the payment, up to 4 KB once serialised. Never shown to the payer and never returned. |
curl -X POST https://api.tolasaint.com/v1/bakong \ -H "x-api-key: sk_live_your_key_here" \ -H "content-type: application/json" \ -d '{"amount":"1.00","currency":"USD","reference":"order-778"}'
| Field | Type | Description |
|---|---|---|
| id | string | Payment id, 12 characters. Use it to check status. |
| status | string | Always "pending" on creation. |
| provider | "bakong" | Always "bakong" on this route. |
| amount | string | The amount you sent, unchanged. |
| currency | string | The currency you sent. |
| qr_link | string | Hosted QR image. Embeddable and safe to show the payer; no API key needed. |
| qr_string | string | Raw KHQR payload. Built here rather than fetched from a bank, then verified against the amount and currency you asked for before it is returned. |
| expires_at | string | ISO 8601 expiry, from the server TTL. After this the payment reports expired. |
| reference | string | Present only when you sent one. |
// 201 Created { "id": "AbC123xyz789", "status": "pending", "provider": "bakong", "amount": "1.00", "currency": "USD", "qr_link": "https://api.tolasaint.com/qr/uQH8rR0AIv0djTnPLJ3N", "qr_string": "00020101021229190015your_name@aclb...", "expires_at": "2026-01-01T00:03:00.000Z", "reference": "order-778" }
Check a Bakong payment by id. Refuses ABA payments with 400 wrong_provider, so a caller integrating against this route only ever sees the three statuses below.
| Parameter | Type | Description |
|---|---|---|
| id required | string | The payment id returned by create. Only your own payments are visible. |
curl "https://api.tolasaint.com/v1/bakong/status?id=AbC123xyz789" \ -H "x-api-key: sk_live_your_key_here"
| Field | Type | Description |
|---|---|---|
| id | string | The payment id you queried. |
| amount | string | Amount of the payment. |
| currency | string | Currency of the payment. |
| status | string | One of pending, paid or expired. |
| provider | "bakong" | Always "bakong" on this route. |
// 200 OK { "id": "AbC123xyz789", "amount": "1.00", "currency": "USD", "status": "paid", "provider": "bakong" }
| Status | Terminal | Meaning |
|---|---|---|
| pending | no | Created, not paid yet. No webhook is sent for this state. |
| paid | yes | The transaction is in Bakong's ledger. This is the state that means you have the money. |
| expired | yes | The window in expires_at passed without payment. |
That's the whole set. Bakong has no scanned, processing or failed: a transaction is either in the ledger or it isn't, so there is nothing in between to report.
Polling is cheap, and safe to do often
This reads our own record rather than calling the bank on your behalf, so it answers in a few milliseconds however often you ask. A background check refreshes every payment every couple of seconds, which means the status you get can be a moment behind — if you need to know the instant a payment lands, use a webhook instead of polling.
Lifetime totals for your account — the same figures the dashboard Overview shows. Takes no parameters.
curl https://api.tolasaint.com/v1/stats \
-H "x-api-key: sk_live_..."| Field | Type | Description |
|---|---|---|
| total | number | Every payment you have ever created. |
| by_status | object | Count per status, with a key for each of the seven statuses. |
| pending_now | number | Payments still in flight — pending, scanned and processing combined. |
| approved_volume | object | Settled totals as decimal strings, keyed by currency: { "USD": "…", "KHR": "…" }. Counts both approved and paid, so it covers both rails. |
| success_rate | number | null | Succeeded divided by settled — (approved + paid) over (approved + paid + failed + expired) — as a fraction between 0 and 1. Null until something settles. |
// 200 OK { "total": 181, "by_status": { "pending": 0, "scanned": 0, "processing": 0, "approved": 9, "paid": 4, "failed": 2, "expired": 170 }, "pending_now": 0, "approved_volume": { "USD": "4.12", "KHR": "0" }, "success_rate": 0.07 }
success_rate is a fraction, so multiply by 100 for a percentage. Expired payments count as settled, which is why an expiring QR drags the rate down. approved_volume and success_rate both count approved and paid, so the figures already cover both rails.
When a payment changes status, Tola Saint sends a signed POST to your webhook URL for scanned, processing, approved, paid, failed, and expired. Nothing is sent for pending.
A Bakong payment only ever fires two of those: paid or expired. So a handler that keys off approved alone will never hear about a Bakong sale.
| Header | Type | Description |
|---|---|---|
| x-webhook-signature | string | sha256=<hex>. HMAC-SHA256 of timestamp + "." + rawBody, keyed by your signing secret. |
| x-webhook-timestamp | string | Epoch milliseconds, as sent. Feed it into the signature exactly as received. |
| x-webhook-id | string | Unique per delivery. Use it to make your handler idempotent across retries. |
| content-type | string | Always application/json. |
| user-agent | string | Always aba-khqr-api-webhook/1, if you want to allowlist it at your edge. |
| Field | Type | Description |
|---|---|---|
| id | string | Payment id the event is about. |
| reference | string | null | Your reference, or null if you did not send one. |
| amount | string | Amount of the payment. |
| currency | string | Currency of the payment. |
| status | string | The new status: scanned, processing, approved, paid, failed or expired. On Bakong the only success value is paid. |
| provider | "aba" | "bakong" | Which rail the payment ran on, so you never have to infer it from the status value. |
| paid_at | string | null | ISO 8601 time the money arrived — set for approved and paid alike — or null if it has not. |
| occurred_at | string | ISO 8601 time this event was generated. |
{
"id": "AbC123xyz789",
"reference": "order-778",
"amount": "1.00",
"currency": "USD",
"status": "approved",
"provider": "aba",
"paid_at": "2026-01-01T00:01:12.000Z",
"occurred_at": "2026-01-01T00:01:13.000Z"
}| Behaviour | Value | Notes |
|---|---|---|
| Success | 2xx | Any 2xx counts as delivered. Anything else, or a timeout, is a failure. |
| Timeout | 10s | Per attempt. Acknowledge fast and do your work afterwards. |
| Retries | up to 12 attempts | The first 5 come quickly with exponential backoff (1s, 2s, 4s, 8s). After that a background sweep keeps retrying with a widening gap, up to an hour apart, for 24 hours. So an endpoint that was down for a while still gets the event once it is back. |
| Idempotency | one per status | At most one delivery per payment per status, so retries repeat the same x-webhook-id rather than inventing a new event. |
| Ordering | not guaranteed | Retries mean a later status can land first. Trust the status field, not arrival order. |
Recompute HMAC-SHA256(secret, timestamp + "." + rawBody) and compare to the x-webhook-signature header.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(headers, rawBody, secret) {
const ts = headers["x-webhook-timestamp"];
const sig = headers["x-webhook-signature"] || "";
const expected =
"sha256=" + createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}