Reference

Tola Saint API

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.

CurrencyFormatExamples
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.

Payment rails

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.

handling both
// treat either success value as paid
if (res.status === "approved" || res.status === "paid") {
  fulfilOrder(res.id)
}

How they compare

AspectSame?Detail
Setup differsABA needs a hosted PayWay link per currency. Bakong needs one account ID, like your_name@aclb, which covers both currencies.
Creating a QR differsABA is generated by the bank, so create can fail on an upstream outage. Bakong is built locally, so it cannot.
Statuses differsABA reports pending, scanned, processing, then approved or failed. Bakong reports pending, then paid — it has no intermediate reporting to expose.
Success value differsapproved on ABA, paid on Bakong. Treat both as success, or read the provider field and branch.
Expiry sameBoth expire at expires_at and both report expired when the window closes.
Everything else sameSame endpoints, same request shape, same webhooks, same QR link. Only the values above change.

Which rail a payment uses

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.

CallRailDetail
POST /v1/bakong bakongAlways 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 abaUses 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 bakongIf 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 winsOn /v1/payment an explicit "aba" or "bakong" overrides the above. It still fails with merchant_not_configured if that rail is not set up.
Adding a Bakong account ID never moves your existing /v1/payment traffic onto Bakong. ABA keeps priority there, so the statuses your code already handles don't change under you.

Authentication

Authenticate every request with your secret key in the x-api-key header.

HeaderTypeDescription
x-api-key required stringYour secret key from the dashboard. Required on every endpoint except the public QR link and the health check.
request
curl https://api.tolasaint.com/v1/payment/status?id=AbC123 \
  -H "x-api-key: sk_live_your_key_here"
Keep secret keys server-side. A missing or invalid key returns 401 unauthorized.

Health check

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

Errors & limits

Every error uses the same shape:

{ "error": "bad_request", "message": "Invalid amount" }
FieldTypeDescription
error stringStable machine-readable code. Branch on this, never on the message.
message stringHuman-readable explanation, safe to log. The wording may change.

Status codes

StatuserrorMeaning
400 bad_requestInvalid body or query — a malformed amount, a missing currency, an unparseable filter.
400 merchant_not_configuredThe 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_mismatchThe link configured for that currency turned out to be for the other one. Nothing was charged; fix the link under Merchant Settings.
401 unauthorizedMissing or invalid x-api-key.
404 not_foundUnknown payment id, or one belonging to another merchant. The two are deliberately indistinguishable.
400 wrong_providerFrom /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_errorThe request itself was malformed before it reached a handler — unparseable JSON, or a content-type the endpoint does not accept.
429 request_errorOver 120 requests in a minute for this key. Wait for retry-after, then retry.
502 upstream_errorABA refused or timed out. Safe to retry.
503 provider_unavailableYou 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_failedThe provider returned a QR that failed validation, so it was discarded instead of handed to you.
500 internal_errorUnexpected server fault. Never carries internal detail.

Rate limits

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:

HeaderTypeDescription
x-ratelimit-limit numberRequests allowed in the window. Currently 120.
x-ratelimit-remaining numberRequests left in the current window.
x-ratelimit-reset numberSeconds until the window resets.
retry-after numberSeconds to wait. Sent only on a 429.
POST/v1/payment

Create a KHQR payment. Returns a QR string, a hosted QR link, and an expiry.

Body parameters

ParameterTypeDescription
amount required stringPositive 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 stringYour own order or invoice number, up to 128 characters. Echoed back and included in webhooks. Not required to be unique.
metadata objectFree-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"}'

Response fields

FieldTypeDescription
id stringPayment id, 12 characters. Use it to check status.
status stringAlways "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 stringThe amount you sent, unchanged.
currency stringThe currency you sent.
qr_link stringHosted QR image. Embeddable and safe to show the payer; no API key needed.
qr_string stringRaw 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 stringISO 8601 expiry. Taken from ABA when it supplies one, otherwise from the server TTL. Bakong QRs always use the server TTL.
reference stringPresent 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"
}
GET/v1/payment/status

Check a transaction by id.

Query parameters

ParameterTypeDescription
id required stringThe 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_..."

Response fields

FieldTypeDescription
id stringThe payment id you queried.
amount stringAmount of the payment.
currency stringCurrency of the payment.
status stringCurrent 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 values

pending scanned processing approved paid failed expired
StatusRailMeaning
pending bothCreated, not paid yet. Not terminal, and no webhook is sent for it. Every payment starts here.
scanned abaThe payer opened the QR in their banking app. Not terminal.
processing abaThe bank is settling the transfer. Not terminal.
approved abaPaid. ABA's success state, and terminal — the money is yours.
paid bakongPaid. Bakong's success state, and terminal. Means exactly what approved means on ABA.
failed abaThe bank rejected or the payer abandoned the transfer. Terminal.
expired bothThe 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.

a Bakong payment
// 200 OK — note "paid", not "approved"
{ "id": "AbC123xyz789", "amount": "1.00", "currency": "USD", "status": "paid", "provider": "bakong" }
GET/v1/payments

Page through your payments, newest first. Every parameter is optional, so a bare call returns the 20 most recent.

Query parameters

ParameterTypeDescription
limit numberHow many to return, 1 to 100. Defaults to 20.
offset numberHow many to skip, for paging through the result. Defaults to 0.
status stringReturn 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 stringCase-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_..."

Response fields

FieldTypeDescription
data arrayThe matching payments, newest first. Fields are listed below.
total numberHow many payments match the filters in total, ignoring limit and offset.
limit numberThe limit that was applied.
offset numberThe offset that was applied.

Each item in data

FieldTypeDescription
id stringPayment id.
status stringCurrent status.
provider "aba" | "bakong"Which rail issued the QR.
amount stringAmount of the payment.
currency stringCurrency of the payment.
reference string | nullYour reference, or null.
created_at stringISO 8601 time the payment was created.
updated_at stringISO 8601 time the status last changed.
paid_at string | nullISO 8601 time the money arrived — when the status became approved or paid — or null if it has not.
expires_at stringISO 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
}
This view never includes qr_string or qr_link. Capture those from the create response if you need to show a QR again.
GET/qr/:token

The qr_link renders the KHQR as a scannable image. It's a public capability URL protected by an unguessable token — embed it directly.

Path parameters

ParameterTypeDescription
token required stringThe 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.

The link stops working once the QR is dead

Payment stateResponseWhy
pending, scanned, processing 200Still payable, so the image is served.
past expires_at 410 GoneRefused 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 GoneAlready settled. Serving it again would invite a second payment for one order.
failed or expired 410 GoneTerminal; the QR cannot be paid.
unknown token 404Kept 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.

POST/v1/bakong

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.

Body parameters

ParameterTypeDescription
amount required stringPositive 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 stringYour 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 objectFree-form key/value data kept with the payment, up to 4 KB once serialised. Never shown to the payer and never returned.
request
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"}'

Response fields

FieldTypeDescription
id stringPayment id, 12 characters. Use it to check status.
status stringAlways "pending" on creation.
provider "bakong"Always "bakong" on this route.
amount stringThe amount you sent, unchanged.
currency stringThe currency you sent.
qr_link stringHosted QR image. Embeddable and safe to show the payer; no API key needed.
qr_string stringRaw 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 stringISO 8601 expiry, from the server TTL. After this the payment reports expired.
reference stringPresent 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"
}
Requires a Bakong account ID under Merchant Settings. Without one you get 400 merchant_not_configured.
GET/v1/bakong/status

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.

Query parameters

ParameterTypeDescription
id required stringThe payment id returned by create. Only your own payments are visible.
request
curl "https://api.tolasaint.com/v1/bakong/status?id=AbC123xyz789" \
  -H "x-api-key: sk_live_your_key_here"

Response fields

FieldTypeDescription
id stringThe payment id you queried.
amount stringAmount of the payment.
currency stringCurrency of the payment.
status stringOne 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 values

pending paid expired
StatusTerminalMeaning
pending noCreated, not paid yet. No webhook is sent for this state.
paid yesThe transaction is in Bakong's ledger. This is the state that means you have the money.
expired yesThe 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.

GET/v1/stats

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_..."

Response fields

FieldTypeDescription
total numberEvery payment you have ever created.
by_status objectCount per status, with a key for each of the seven statuses.
pending_now numberPayments still in flight — pending, scanned and processing combined.
approved_volume objectSettled totals as decimal strings, keyed by currency: { "USD": "…", "KHR": "…" }. Counts both approved and paid, so it covers both rails.
success_rate number | nullSucceeded 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.

Webhooks

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.

Request headers we send

HeaderTypeDescription
x-webhook-signature stringsha256=<hex>. HMAC-SHA256 of timestamp + "." + rawBody, keyed by your signing secret.
x-webhook-timestamp stringEpoch milliseconds, as sent. Feed it into the signature exactly as received.
x-webhook-id stringUnique per delivery. Use it to make your handler idempotent across retries.
content-type stringAlways application/json.
user-agent stringAlways aba-khqr-api-webhook/1, if you want to allowlist it at your edge.

Payload fields

FieldTypeDescription
id stringPayment id the event is about.
reference string | nullYour reference, or null if you did not send one.
amount stringAmount of the payment.
currency stringCurrency of the payment.
status stringThe 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 | nullISO 8601 time the money arrived — set for approved and paid alike — or null if it has not.
occurred_at stringISO 8601 time this event was generated.
payload
{
  "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"
}

Delivery

BehaviourValueNotes
Success 2xxAny 2xx counts as delivered. Anything else, or a timeout, is a failure.
Timeout 10sPer attempt. Acknowledge fast and do your work afterwards.
Retries up to 12 attemptsThe 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 statusAt most one delivery per payment per status, so retries repeat the same x-webhook-id rather than inventing a new event.
Ordering not guaranteedRetries mean a later status can land first. Trust the status field, not arrival order.

Verify signatures

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);
}