Skip to content
Appearance

From PayWay link to settled payment.

Create a payment from your server, render the returned KHQR, and poll until it is paid or expires. The QR belongs in your checkout; your API key does not.

Base URL
https://api.bongluy.com
Local API
http://localhost:8888
Auth
Bearer sk_live_...
Bodies
application/json

Integrating with an AI agent?

Copy the whole contract as a prompt and paste it into Claude, Cursor, or ChatGPT. The same text is served as plain markdown at /llms.txt.

The integration path

A store is registered once. An account can hold many stores, each with its own PayWay link, and the store you name on a payment decides where the money lands. Everything after it repeats for each sale and remains addressable with your own identifiers.

  1. Authenticatesk_live_...
  2. Register storePOST /stores
  3. Create paymentPOST /payment
  4. Present checkoutqrString
  5. Observe outcomestatus -> detail

Your first payment

Register the PayWay link you collect into, then create a payment with a decimal string and a tranId from your own system. The response contains the payment UUID, KHQR payload, optional mobile deeplinks, a hosted checkout URL, and the exact expiry time.

# Create a store once
curl -X POST https://api.bongluy.com/stores \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"name":"Tykea Coffee","merchantStoreId":"branch-2","paywayLink":"https://link.payway.com.kh/aba?id=..."}'

# Create one payment per sale
curl -X POST https://api.bongluy.com/payment \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"merchantStoreId":"branch-2","amount":"12.50","tranId":"INV-1042"}'

Authentication stays on your server.

Authenticated store and payment routes accept an account-level API key.

Credential
Authorization: Bearer sk_live_...
Held by
Your server
Access
Stores and payments

Obtain a key

Log in, open the API keys page, create a key, and copy it.

Copy this key now

The app shows a new key only once.

Shown once
sk_live_...

Store the copied value in your server's .env file, for example BONGLUY_API_KEY=sk_live_.... Keep it on the server and never expose it in browser code.

Key facts worth knowing up front

Scope
Keys belong to the account, not to one store. A key reaches every store the account owns.
Lifetime
90 days. Rotate before the deadline; an expired key fails as 401, the same as an invalid one.
Name
Up to 32 characters. It is a label for you and carries no meaning to the API.
Recovery
None. Only the hash is kept, so a lost key must be replaced rather than looked up.

One account, as many stores as you need.

An account can hold any number of stores, and each store carries its own ABA PayWay link. A payment belongs to exactly one store, so the store you name on a create call decides which PayWay link — and therefore which ABA account — receives the money.

Use one store per destination you collect into: a branch, an outlet, a brand, or a separate business line. Registering several is normal, and there is no limit to plan around. Your single API key already reaches all of them, so switching destinations is a matter of naming a different store on the request, not of holding a second credential.

Because the link is per store, two payments created a second apart can settle into two different ABA accounts. If your system routes money by branch or by seller, model that as one store each rather than swapping the link on a shared store.
storeId

Bongluy's UUID for the store.

merchantStoreId

Your existing identifier, such as branch-2 or SHOP-KH-01.

Send one identifier or the other. A request with neither fails validation; if both are present, storeId wins. Register merchantStoreId when you create the store and you never have to store Bongluy'sstoreId at all — your own id addresses the store everywhere it is accepted.

Omitting merchantStoreId is valid, and stores without one do not collide with each other. Those stores can only be addressed with Bongluy's storeId, which is the mapping supplying your own id avoids.

Store object
{
  "id": "76e0cdea-ab74-455d-b46e-3b9e50f679a2",
  "userId": "c1f0...",
  "name": "Tykea Coffee",
  "merchantStoreId": "branch-2",
  "paywayLink": "https://link.payway.com.kh/aba?id=...",
  "webhookUrl": null,
  "active": true,
  "createdAt": "2026-08-14T03:11:22.418Z",
  "updatedAt": "2026-08-14T03:11:22.418Z"
}
Field
id
Type
string
Meaning
Bongluy's UUID for the store. Accepted as storeId everywhere else.
Field
userId
Type
string
Meaning
The account that owns the store. Taken from your credential at creation.
Field
name
Type
string
Meaning
The display name you chose. Unique within the account.
Field
merchantStoreId
Type
string | null
Meaning
Your own identifier, or null if you did not supply one.
Field
paywayLink
Type
string
Meaning
The ABA PayWay link that receives funds for this store.
Field
webhookUrl
Type
string | null
Meaning
Stored for future use. Nothing is delivered to it today.
Field
active
Type
boolean
Meaning
False blocks new payments. Existing payments stay readable.
Field
createdAt
Type
string
Meaning
ISO 8601 timestamp.
Field
updatedAt
Type
string
Meaning
ISO 8601 timestamp of the last write.

webhookSecret is accepted on writes but never returned.

GET/stores

List every store owned by the account.

scope store:read

Takes no parameters. The response is a bare array of store objects — not a paginated envelope. There is no filtering, and ordering is not guaranteed.

curl https://api.bongluy.com/stores \
  -H 'authorization: Bearer sk_live_...'
POST/stores/detail201

Read one store by either identifier.

scope store:read

Name the store in the body and the response is the same store object GET /stores returns for it. It is a POST because the identifier travels in the body; there is noGET /stores/:id.

Field
storeId
Type
string
Required
Either
Notes
Bongluy's UUID for the store
Field
merchantStoreId
Type
string
Required
Either
Notes
Your own identifier. Ignored if storeId is also present
curl -X POST https://api.bongluy.com/stores/detail \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"merchantStoreId":"branch-2"}'

Missing and foreign stores both return404 "Store not found", for the same reason as on update: a 403 would confirm that the id exists.

A deactivated store is still readable.active is reported here, not enforced, so turning a store off never hides it.

POST/stores201

Create a store and bind its PayWay link.

scope store:create
Field
name
Type
string
Required
Yes
Notes
1-200 characters; unique within the account
Field
paywayLink
Type
URL string
Required
Yes
Notes
An https link on link.payway.com.kh. The link must collect in USD; KHR links are rejected
Field
merchantStoreId
Type
string
Required
No
Notes
1-200 characters; unique within the account
Field
webhookUrl
Type
URL string
Required
No
Notes
Stored but not delivered to
Field
webhookSecret
Type
string
Required
No
Notes
Stored and never returned
curl -X POST https://api.bongluy.com/stores \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"name":"Tykea Coffee","merchantStoreId":"branch-2","paywayLink":"https://link.payway.com.kh/aba?id=..."}'

userId comes from the credential. Unknown fields are stripped. Reusing merchantStoreId returns409. Duplicate store names are also disallowed, but currently surface as 500 rather than a clean conflict.

POST/stores/update201

Partially update or deactivate a store.

scope store:update

Name the store at the top level and put changed values underchanges. This is intentionally not a PATCH or PUT route, just as reads go through POST /stores/detail.

changes field
name
Type
string
Notes
Same rules as create
changes field
paywayLink
Type
URL string
Notes
Same https, host, and USD rules as create. Used for future payments
changes field
merchantStoreId
Type
string
Notes
Must remain unique within the account
changes field
webhookUrl
Type
URL string
Notes
Stored for future delivery
changes field
webhookSecret
Type
string
Notes
Stored and never returned
changes field
active
Type
boolean
Notes
False blocks new payments
curl -X POST https://api.bongluy.com/stores/update \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"merchantStoreId":"branch-2","changes":{"active":false}}'

To rename a merchant id, use the old value at the top level and the new value inside changes. An empty object, or one containing only misspelled fields, returns400 "No fields to update".

Missing and foreign stores both return404 "Store not found". Renaming into another store's merchant id returns 409.

Deactivation is the closest operation to deletion. It blocks new payments but leaves the store and its payment history readable.

Create one payment per checkout.

The create call reserves a KHQR and snapshots the store's PayWay link onto a durable payment record.

POST/payment201

Create a payment and reserve its KHQR.

scope payment:create
Field
amount
Type
string
Required
Yes
Notes
Decimal with zero, one, or two fractional digits
Field
storeId / merchantStoreId
Type
string
Required
Either
Notes
The active store receiving funds
Field
tranId
Type
string
Required
No
Notes
Your transaction id and idempotency key
Field
currency
Type
string
Required
No
Notes
Metadata only; defaults to USD
curl -X POST https://api.bongluy.com/payment \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"merchantStoreId":"branch-2","amount":"12.50","tranId":"INV-1042"}'
Payment response
{
  "id": "8435481a-48a8-4bb2-91d2-bcd1e604fb17",
  "storeId": "76e0cdea-ab74-455d-b46e-3b9e50f679a2",
  "status": "PENDING",
  "amount": "12.50",
  "currency": "USD",
  "paywayLink": "https://link.payway.com.kh/aba?id=...",
  "checkoutUrl": "https://www.bongluy.com/payment/8435481a-48a8-4bb2-91d2-bcd1e604fb17",
  "expireAt": "2026-08-14T03:14:22.418Z",
  "settledTranId": null,
  "receiptUrl": null,
  "tranId": "INV-1042",
  "lastError": null,
  "createdAt": "2026-08-14T03:11:22.418Z",
  "updatedAt": "2026-08-14T03:11:22.418Z",
  "settledAt": null,
  "qrString": "00020101021230...",
  "deeplink": {
    "scheme": "abamobilebank://ababank.com?type=payway&qrcode=...",
    "android": "intent://ababank.com?type=payway&qrcode=...#Intent;package=com.paygo24.ibank;..."
  }
}
Field
id
Type
string
Meaning
Bongluy's UUID for the payment. Pass it back as id on the status and detail routes.
Field
storeId
Type
string
Meaning
The store that receives the funds.
Field
status
Type
string
Meaning
PENDING, SUCCESS, EXPIRED, or FAILED. Always PENDING at creation.
Field
amount
Type
string
Meaning
The decimal you asked for, echoed back unchanged.
Field
currency
Type
string
Meaning
Label only. It does not instruct PayWay or convert funds.
Field
paywayLink
Type
string
Meaning
The store's PayWay link as it was at creation. Later edits to the store do not rewrite it.
Field
checkoutUrl
Type
string
Meaning
Hosted Bongluy checkout page for this payment. Safe to send straight to a payer.
Field
expireAt
Type
string
Meaning
ISO 8601. After this instant the payment can no longer be paid upstream.
Field
qrString
Type
string | null
Meaning
The KHQR payload. Encode it yourself to draw the QR.
Field
deeplink
Type
object | null
Meaning
scheme opens ABA Mobile on iOS; android is an intent URL. Null whenever qrString is null.
Field
tranId
Type
string | null
Meaning
Your transaction id, exactly as you sent it.
Field
settledTranId
Type
string | null
Meaning
ABA's id for the completed transaction. Null until SUCCESS.
Field
receiptUrl
Type
string | null
Meaning
ABA receipt link. Null until SUCCESS.
Field
settledAt
Type
string | null
Meaning
ISO 8601 instant of settlement. Null until SUCCESS.
Field
lastError
Type
string | null
Meaning
The most recent upstream failure for this payment, if any.
Field
createdAt
Type
string
Meaning
ISO 8601 timestamp.
Field
updatedAt
Type
string
Meaning
ISO 8601 timestamp of the last change to the record.

checkoutUrl is the hosted Bongluy checkout page for this payment. It is safe to hand to a payer directly, so an integration that does not want to render its own QR can redirect to it or share it as a link.

Money on the wire

amount is a string because decimal money should not pass through JSON floating-point values. It must match^\d+(\.\d{1,2})?$. The API currently accepts "0" and "0.00", so enforce your own minimum before calling it.

currency labels the record; it does not instruct PayWay or convert funds. The actual charge follows the store's PayWay link. A mismatched value only mislabels your own data.

Use tranId for safe retries

tranId is unique within a store. Repeating a create request with an existing tranId returns the original payment verbatim, without a second upstream call. This makes a timed-out create safe to retry. Both payment read routes also accept tranId, so your system can work entirely with its own store and transaction identifiers.

Payments without tranId do not collide, but a lost create response cannot then be recovered through your own identifier, which leaves Bongluy's UUID as the only way back to the payment. Send a tranId on every create and that case disappears.

settledTranId is different. It is ABA's identifier for the completed transaction and appears only after success.

You do not need to store Bongluy's ids

Integrating requires no new table, no migration, and no new columns for id or storeId. Every route can be addressed with identifiers your system already owns, so Bongluy's UUIDs never have to be persisted.

Your existing identifier
Your store, branch, or outlet id
Field you send
merchantStoreId
Accepted by
Every store-taking route, in place of storeId
Your existing identifier
Your order, invoice, or sale id
Field you send
tranId
Accepted by
POST /payment, /payment/status, /payment/detail

Register merchantStoreId when the store is created and send a tranId on every payment. That pair then addresses the payment for the rest of its life — status, detail, and retries alike. Bongluy stays the system of record for payment state; read it back with the pair rather than mirroring it locally.

Reading the returned UUIDs within a request is fine. Writing them to your database is the part that is unnecessary. Two limits are worth knowing first: a store registered without amerchantStoreId can only be addressed by itsstoreId, and the exact lookup bytranId is POST /payment/detail.

Expiry is part of the response

ABA supplies the lifetime; Bongluy falls back to 180 seconds only when ABA omits it. Read expireAt from every payment response instead of hardcoding three minutes. A payment past that timestamp cannot be paid upstream.

An inactive, missing, or foreign store returns404 "unknown store". The response deliberately does not reveal which condition applied.

GET/payment

List one store's payments, newest first.

scope payment:read
Query
storeId / merchantStoreId
Default
-
Notes
Either store identifier is required
Query
page
Default
1
Notes
Integer at least 1
Query
per_page
Default
25
Notes
Integer from 1 to 100; snake_case
Query
status
Default
-
Notes
PENDING, SUCCESS, EXPIRED, or FAILED
curl 'https://api.bongluy.com/payment?merchantStoreId=branch-2&status=SUCCESS&per_page=50' \
  -H 'authorization: Bearer sk_live_...'
Paginated response
{
  "data": [
    {
      "id": "8435481a-48a8-4bb2-91d2-bcd1e604fb17",
      "status": "SUCCESS",
      "amount": "12.50",
      "tranId": "INV-1042"
    }
  ],
  "page": 1,
  "per_page": 50,
  "total": 137,
  "total_pages": 3
}
Field
data
Type
array
Meaning
Payment objects, newest first. Each one is abbreviated above but complete in the real response.
Field
page
Type
number
Meaning
The page you are on, echoing the page query.
Field
per_page
Type
number
Meaning
How many records this page can hold. Note the snake_case.
Field
total
Type
number
Meaning
Total matching payments across every page.
Field
total_pages
Type
number
Meaning
How many pages exist at this per_page. Stop when page reaches it.

Each element of data is the same payment object the create route returns, checkoutUrl included, so a payment link can be recovered from the list without a second lookup.

Spelling per_page as perPage does not fail; the unknown query is stripped and the response silently uses 25. There is no list filter for tranId. Use the exact detail lookup instead.

A deactivated store's history remains listable. Ownership, not active state, controls access to existing payments.

The checkout owns the QR.

The create response gives you the raw material for desktop, point-of-sale, and mobile checkout. Keep the KHQR payload as the source of truth.

Render qrString

Encode qrString with any QR library and show the result with the payment amount and expiry. On desktop it is the primary path; on mobile it remains the fallback if the banking app cannot open.

Offer the ABA Mobile deeplink when present

deeplink.scheme opens ABA Mobile on iOS.deeplink.android is an intent URL that can fall through to the Play Store. The iOS scheme silently does nothing when the app is absent.

Choose the mobile deeplink
const isAndroid = /android/i.test(navigator.userAgent);
const href = isAndroid
  ? payment.deeplink.android
  : payment.deeplink.scheme;
The deeplink is derived from ABA's checkout client and is undocumented upstream. It can change without notice. Keep the QR visible and treat qrString as authoritative.

deeplink is null whenever qrString is null.

Poll for speed, read detail for truth.

The status route is the lightweight observation channel. The detail route is the durable record used for receipts and reconciliation.

POST/payment/status201

Read the current payment state.

scope payment:read

Send a store identifier plus either the payment UUID inid or your own tranId. If both payment identifiers are present, id wins.

curl -X POST https://api.bongluy.com/payment/status \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"merchantStoreId":"branch-2","tranId":"INV-1042"}'
Successful status response
{
  "paymentId": "8435481a-48a8-4bb2-91d2-bcd1e604fb17",
  "status": "SUCCESS",
  "expireAt": 1786763662418,
  "settledTranId": "1234567890",
  "receipt": "https://...",
  "at": 1786763501992
}
Field
paymentId
Type
string
Meaning
Bongluy's UUID for the payment. Named id on every other route.
Field
status
Type
string
Meaning
PENDING, SUCCESS, EXPIRED, or FAILED.
Field
expireAt
Type
number
Meaning
Epoch milliseconds, not the ISO string the payment object uses.
Field
at
Type
number
Meaning
Epoch milliseconds when this reading was taken. Useful for ordering poll results.
Field
settledTranId
Type
string
Meaning
ABA's transaction id. The key is absent, not null, while pending.
Field
receipt
Type
string
Meaning
ABA receipt link, named receiptUrl on the payment object. Absent while pending.
This payload is not the payment object. Here,expireAt and at are epoch milliseconds, and the receipt field is namedreceipt. While pending,settledTranId and receipt are absent, not null.

Recent payments are served from a fast in-memory snapshot for the first 15 minutes and fall back to the stored row later. The route remains usable indefinitely; only latency changes.

PENDING

Created and awaiting payment.

SUCCESS

Paid and settled.

EXPIRED

Expired without payment.

FAILED

Reserved for a future non-success terminal state.

Stop at the deadline

Poll every two or three seconds until status leavesPENDING or the returned expiry time passes. There is no streaming or long-polling endpoint.

async function waitForPayment({ merchantStoreId, tranId, expireAt }) {
  const deadline = new Date(expireAt).getTime();

  while (Date.now() < deadline) {
    const response = await fetch("https://api.bongluy.com/payment/status", {
      method: "POST",
      headers: {
        authorization: `Bearer ${process.env.BONGLUY_API_KEY}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({ merchantStoreId, tranId }),
    });
    const { status } = await response.json();

    if (status !== "PENDING") return status;
    await new Promise((resolve) => setTimeout(resolve, 2500));
  }

  return "EXPIRED";
}
If upstream polling exhausts its retries, a payment can remainPENDING after expireAt. Treat it as unpaid, stop checkout polling, and reconcile later. Do not leave the payer watching an endless spinner.

At 2.5-second intervals, one three-minute checkout consumes about 72 calls. Roughly eight concurrent checkouts can saturate a 600-request-per-minute key. Poll once per payment, not from every open browser tab.

POST/payment/detail201

Read the complete durable payment record.

scope payment:read

The lookup body is identical to the status route, so your ownmerchantStoreId and tranId are enough to retrieve it. The response matches the create response, including amount, currency, tranId,receiptUrl, settledAt, ISO timestamps, QR, and deeplinks.

curl -X POST https://api.bongluy.com/payment/detail \
  -H 'authorization: Bearer sk_live_...' \
  -H 'content-type: application/json' \
  -d '{"merchantStoreId":"branch-2","tranId":"INV-1042"}'

A store that is absent or belongs to another account returns404 "unknown store". A payment that is absent from a store you own returns404 "unknown payment". Foreign resources are deliberately indistinguishable from missing ones.

Errors are JSON, with one important variation.

Most failures use a statusCode, message, and error envelope. Validation changes message from a string to an array of strings.

Validation error
{
  "statusCode": 400,
  "message": [
    "amount must be a positive decimal",
    "storeId must be a UUID"
  ],
  "error": "Bad Request"
}
Field
statusCode
Type
number
Meaning
Repeats the HTTP status, so you can read it without inspecting the response object.
Field
message
Type
string | string[]
Meaning
One sentence for most failures; an array of field complaints for validation. Branch on the type before displaying it.
Field
error
Type
string
Meaning
The HTTP reason phrase, such as Bad Request. Not a stable machine-readable code.

Either/or identifier pairs can produce several messages for one omission. If neither store identifier is present, validation may complain about both storeId andmerchantStoreId; supplying either resolves the group.

Status
400
Meaning
Validation failure, or a store update with no recognized changes
Status
401
Meaning
Missing, invalid, expired, revoked, or under-scoped API key
Status
403
Meaning
Unrecognized origin on a public route
Status
404
Meaning
Missing or foreign store/payment
Status
409
Meaning
merchantStoreId already in use
Status
429
Meaning
Rate limit exceeded
Status
500
Meaning
PayWay failure, server failure, or currently a duplicate store name
All API-key failures collapse to401 Unauthorized. When a previously working key starts failing, check its 90-day age before changing request code.

PayWay failures from POST /payment currently appear as 500, not 502 or 503. Retry with the same tranId so a request that actually succeeded upstream cannot create a duplicate payment.

Rate limits

Authenticated API
600 requests per minute, per API key. The 429 response has no Retry-After header; back off for the rest of the minute.
Store writes
20 per minute, per account. Creating and updating stores is setup work, not per-sale work.
Checkout page, per payment
20 requests per second. This governs the hosted checkout a payer has open, not your server's polling.
Checkout page, overall
200 requests per second across all payments.

Better Auth key-management endpoints use their own envelope,{ "code": "...", "message": "..." }. Store and payment routes use the standard status-code shape above.

Webhooks are not available yet.

Poll POST /payment/status for checkout feedback and read POST /payment/detail when you need the durable record.

webhookUrl and webhookSecret are accepted on stores and stored, but nothing reads them. Configuring them today will not produce a delivery.

Per-store webhooks and a signed payload are planned, but the payload and signature scheme are deliberately undocumented until they ship. Polling remains the supported integration contract.

A server-configured global delivery endpoint may exist in some deployments, but it is not exposed to merchants and is not part of this API contract.