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.

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

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_configuredNo ABA merchant link on your account yet. Add it 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.
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.
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. Anything else is rejected.
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.
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.
reference stringPresent only when you sent one.
// 201 Created
{
  "id": "AbC123xyz789",
  "status": "pending",
  "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.
// 200 OK
{ "id": "AbC123xyz789", "amount": "1.00", "currency": "USD", "status": "approved" }

Status values

pending scanned processing approved failed expired
StatusTerminalMeaning
pending noCreated, not yet scanned. No webhook is sent for this state.
scanned noThe payer opened the QR in their banking app.
processing noThe bank is settling the transfer.
approved yesPaid. This is the only state that means you have the money.
failed yesThe bank rejected or the payer abandoned the transfer.
expired yesThe window in expires_at passed without payment.

Terminal states never change again, so you can stop polling once you see one.

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.

Webhooks

When a payment changes status, Tola Saint sends a signed POST to your webhook URL for scanned, processing, approved, failed, and expired.

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.

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, failed or expired.
paid_at string | nullISO 8601 time the payment was approved, or null if it has not been.
occurred_at stringISO 8601 time this event was generated.
payload
{
  "id": "AbC123xyz789",
  "reference": "order-778",
  "amount": "1.00",
  "currency": "USD",
  "status": "approved",
  "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 5 attemptsExponential backoff — 1s, 2s, 4s, 8s — capped at 30s between attempts.
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);
}