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.
| 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. |
Authentication
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. |
curl https://api.tolasaint.com/v1/payment/status?id=AbC123 \
-H "x-api-key: sk_live_your_key_here"Errors & limits
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 codes
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Invalid body or query — a malformed amount, a missing currency, an unparseable filter. |
| 400 | merchant_not_configured | No ABA merchant link on your account yet. Add it 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. |
| 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. |
| 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. |
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:
| 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.
Body parameters
| 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. Anything else is rejected. |
| 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"}'Response fields
| Field | Type | Description |
|---|---|---|
| id | string | Payment id, 12 characters. Use it to check status. |
| status | string | Always "pending" on creation. |
| 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. |
| reference | string | Present 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" }
Check a transaction by id.
Query parameters
| 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_..."Response fields
| 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. |
// 200 OK { "id": "AbC123xyz789", "amount": "1.00", "currency": "USD", "status": "approved" }
Status values
| Status | Terminal | Meaning |
|---|---|---|
| pending | no | Created, not yet scanned. No webhook is sent for this state. |
| scanned | no | The payer opened the QR in their banking app. |
| processing | no | The bank is settling the transfer. |
| approved | yes | Paid. This is the only state that means you have the money. |
| failed | yes | The bank rejected or the payer abandoned the transfer. |
| expired | yes | The window in expires_at passed without payment. |
Terminal states never change again, so you can stop polling once you see one.
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
| 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.
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
| 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. |
Payload fields
| 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, failed or expired. |
| paid_at | string | null | ISO 8601 time the payment was approved, or null if it has not been. |
| occurred_at | string | ISO 8601 time this event was generated. |
{
"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
| 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 5 attempts | Exponential backoff — 1s, 2s, 4s, 8s — capped at 30s between attempts. |
| 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. |
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);
}