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
- Credential
x-checkout-key: ...- Held by
- Your checkout server
- Access
- Public payment, status, and retry routes
| Credential | Held by | Access |
|---|---|---|
Authorization: Bearer sk_live_... | Your server | Stores and payments |
x-checkout-key: ... | Your checkout server | Public payment, status, and retry routes |
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.
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. Each store carries required ABA PayWay configuration and may add a Bakong account. Every payment belongs to one store.
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.
storeIdBongluy's UUID for the store.
merchantStoreIdYour 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.
{
"id": "76e0cdea-ab74-455d-b46e-3b9e50f679a2",
"userId": "c1f0...",
"name": "Tykea Coffee",
"merchantStoreId": "branch-2",
"paywayLink": "https://link.payway.com.kh/aba?id=usd...",
"khrPaywayLink": "https://link.payway.com.kh/aba?id=khr...",
"bakongAccountId": "your_name@aclb",
"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.
- Field
merchantStoreId- Type
- string | null
- Meaning
- Your own identifier, or null if you did not supply one.
- Field
paywayLink- Type
- string
- Meaning
- The required USD ABA PayWay link for this store.
- Field
khrPaywayLink- Type
- string | null
- Meaning
- The KHR ABA PayWay link, or null when KHR collection is not configured.
- Field
bakongAccountId- Type
- string | null
- Meaning
- The name@bank account used for Bakong KHQR, or null when the rail is disabled.
- Field
webhookUrl- Type
- string | null
- Meaning
- The per-store terminal-result delivery URL, or null when webhooks are off.
- 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.
| Field | Type | Meaning |
|---|---|---|
id | string | Bongluy's UUID for the store. Accepted as storeId everywhere else. |
userId | string | The account that owns the store. Taken from your credential at creation. |
name | string | The display name you chose. |
merchantStoreId | string | null | Your own identifier, or null if you did not supply one. |
paywayLink | string | The required USD ABA PayWay link for this store. |
khrPaywayLink | string | null | The KHR ABA PayWay link, or null when KHR collection is not configured. |
bakongAccountId | string | null | The name@bank account used for Bakong KHQR, or null when the rail is disabled. |
webhookUrl | string | null | The per-store terminal-result delivery URL, or null when webhooks are off. |
active | boolean | False blocks new payments. Existing payments stay readable. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | ISO 8601 timestamp of the last write. |
webhookSecret is accepted on writes but never returned.
/storesList every store owned by the account.
scope store:readTakes 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_...'/stores/detail201Read one store by either identifier.
scope store:readName 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
| Field | Type | Required | Notes |
|---|---|---|---|
storeId | string | Either | Bongluy's UUID for the store |
merchantStoreId | string | Either | 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.
/stores201Create a store and configure its payment rails.
scope store:create- Field
name- Type
- string
- Required
- Yes
- Notes
- 1-200 characters
- Field
paywayLink- Type
- URL string
- Required
- Yes
- Notes
- An https link on link.payway.com.kh configured to collect USD
- Field
khrPaywayLink- Type
- URL string
- Required
- No
- Notes
- An https link on link.payway.com.kh configured to collect KHR
- Field
bakongAccountId- Type
- string
- Required
- No
- Notes
- A name@bank value up to 32 characters; enables Bakong payments
- Field
merchantStoreId- Type
- string
- Required
- No
- Notes
- 1-200 characters; unique within the account
- Field
webhookUrl- Type
- URL string
- Required
- No
- Notes
- Receives terminal payment results; use https in production
- Field
webhookSecret- Type
- string
- Required
- No
- Notes
- Signs deliveries and is never returned
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | 1-200 characters |
paywayLink | URL string | Yes | An https link on link.payway.com.kh configured to collect USD |
khrPaywayLink | URL string | No | An https link on link.payway.com.kh configured to collect KHR |
bakongAccountId | string | No | A name@bank value up to 32 characters; enables Bakong payments |
merchantStoreId | string | No | 1-200 characters; unique within the account |
webhookUrl | URL string | No | Receives terminal payment results; use https in production |
webhookSecret | string | No | Signs deliveries and is 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=usd...","khrPaywayLink":"https://link.payway.com.kh/aba?id=khr...","bakongAccountId":"your_name@aclb"}'userId comes from the credential. Unknown fields are stripped. Reusing merchantStoreId returns409.
/stores/update201Partially update or deactivate a store.
scope store:updateName 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
- A verified USD link used for future USD payments
- changes field
khrPaywayLink- Type
- URL string
- Notes
- A verified KHR link used for future KHR payments
- changes field
bakongAccountId- Type
- string | null
- Notes
- Replace the Bakong account, or send null to disable the rail
- changes field
merchantStoreId- Type
- string
- Notes
- Must remain unique within the account
- changes field
webhookUrl- Type
- URL string
- Notes
- Replace the delivery URL
- changes field
webhookSecret- Type
- string
- Notes
- Replace the signing secret; never returned
- changes field
active- Type
- boolean
- Notes
- False blocks new payments
| changes field | Type | Notes |
|---|---|---|
name | string | Same rules as create |
paywayLink | URL string | A verified USD link used for future USD payments |
khrPaywayLink | URL string | A verified KHR link used for future KHR payments |
bakongAccountId | string | null | Replace the Bakong account, or send null to disable the rail |
merchantStoreId | string | Must remain unique within the account |
webhookUrl | URL string | Replace the delivery URL |
webhookSecret | string | Replace the signing secret; never returned |
active | boolean | 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.
Create one payment per checkout.
The create call selects ABA PayWay or Bakong, reserves a KHQR, and writes one durable payment record with the same status contract.
/payment201Create 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
- USD | KHR
- Required
- No
- Notes
- Defaults to USD. Requirements depend on provider
- Field
provider- Type
- ABA | BAKONG
- Required
- No
- Notes
- Defaults to ABA. Bakong requires bakongAccountId on the store
| Field | Type | Required | Notes |
|---|---|---|---|
amount | string | Yes | Decimal with zero, one, or two fractional digits |
storeId / merchantStoreId | string | Either | The active store receiving funds |
tranId | string | No | Your transaction id and idempotency key |
currency | USD | KHR | No | Defaults to USD. Requirements depend on provider |
provider | ABA | BAKONG | No | Defaults to ABA. Bakong requires bakongAccountId on the store |
curl -X POST https://api.bongluy.com/payment \
-H 'authorization: Bearer sk_live_...' \
-H 'content-type: application/json' \
-d '{"merchantStoreId":"branch-2","amount":"12500","currency":"KHR","provider":"BAKONG","tranId":"INV-1042"}'{
"id": "8435481a-48a8-4bb2-91d2-bcd1e604fb17",
"storeId": "76e0cdea-ab74-455d-b46e-3b9e50f679a2",
"status": "PENDING",
"amount": "12500",
"currency": "KHR",
"paywayLink": null,
"khqrMd5": "e0f3d55c4ca178f4d2c45e99d4d9d7f8",
"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": null
}- 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
- USD or KHR. Bakong KHR amounts must be whole riel.
- Field
paywayLink- Type
- string | null
- Meaning
- The selected ABA link, or null on Bakong because no PayWay link is involved.
- Field
khqrMd5- Type
- string | null
- Meaning
- Bakong's transaction lookup key. Populated for Bakong payments.
- 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
- ABA Mobile links for ABA payments. Always null on Bakong.
- Field
tranId- Type
- string | null
- Meaning
- Your transaction id, exactly as you sent it.
- Field
settledTranId- Type
- string | null
- Meaning
- The provider's settled transaction reference. Null until SUCCESS.
- Field
receiptUrl- Type
- string | null
- Meaning
- Provider receipt link when available. 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.
| Field | Type | Meaning |
|---|---|---|
id | string | Bongluy's UUID for the payment. Pass it back as id on the status and detail routes. |
storeId | string | The store that receives the funds. |
status | string | PENDING, SUCCESS, EXPIRED, or FAILED. Always PENDING at creation. |
amount | string | The decimal you asked for, echoed back unchanged. |
currency | string | USD or KHR. Bakong KHR amounts must be whole riel. |
paywayLink | string | null | The selected ABA link, or null on Bakong because no PayWay link is involved. |
khqrMd5 | string | null | Bakong's transaction lookup key. Populated for Bakong payments. |
checkoutUrl | string | Hosted Bongluy checkout page for this payment. Safe to send straight to a payer. |
expireAt | string | ISO 8601. After this instant the payment can no longer be paid upstream. |
qrString | string | null | The KHQR payload. Encode it yourself to draw the QR. |
deeplink | object | null | ABA Mobile links for ABA payments. Always null on Bakong. |
tranId | string | null | Your transaction id, exactly as you sent it. |
settledTranId | string | null | The provider's settled transaction reference. Null until SUCCESS. |
receiptUrl | string | null | Provider receipt link when available. Null until SUCCESS. |
settledAt | string | null | ISO 8601 instant of settlement. Null until SUCCESS. |
lastError | string | null | The most recent upstream failure for this payment, if any. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | 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 currency-specific minimums are 0.01 USD and 100 KHR.
currency selects USD or KHR. On ABA it also selects the matching PayWay link. On Bakong no link is involved; KHR amounts must be whole numbers because the KHQR carries no riel decimals.
- Provider
- ABA
- Currency
- USD
- Store requirement
- paywayLink
- Amount rule
- At least 0.01
- Provider
- ABA
- Currency
- KHR
- Store requirement
- khrPaywayLink
- Amount rule
- At least 100
- Provider
- BAKONG
- Currency
- USD
- Store requirement
- bakongAccountId
- Amount rule
- At least 0.01
- Provider
- BAKONG
- Currency
- KHR
- Store requirement
- bakongAccountId
- Amount rule
- Whole riel, at least 100
| Provider | Currency | Store requirement | Amount rule |
|---|---|---|---|
| ABA | USD | paywayLink | At least 0.01 |
| ABA | KHR | khrPaywayLink | At least 100 |
| BAKONG | USD | bakongAccountId | At least 0.01 |
| BAKONG | KHR | bakongAccountId | Whole riel, at least 100 |
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.
The original response also wins if a retry changes amount, currency, or provider. Treat tranId as the identity of one payment intent, not as a request-deduplication header.
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 the provider's completed transaction reference 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
| Your existing identifier | Field you send | Accepted by |
|---|---|---|
| Your store, branch, or outlet id | merchantStoreId | Every store-taking route, in place of storeId |
| Your order, invoice, or sale id | tranId | 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.
merchantStoreId can only be addressed by itsstoreId, and the exact lookup bytranId is POST /payment/detail.Provider clocks are part of the response
Read expireAt from every payment response. ABA payments are normally payable for 24 hours even though automatic status checks pause sooner. A dynamic Bakong KHQR normally expires in about three minutes, and its QR actually dies at that instant.
An inactive, missing, or foreign store returns404 "unknown store". The response deliberately does not reveal which condition applied.
/paymentList 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
| Query | Default | Notes |
|---|---|---|
storeId / merchantStoreId | - | Either store identifier is required |
page | 1 | Integer at least 1 |
per_page | 25 | Integer from 1 to 100; snake_case |
status | - | PENDING, SUCCESS, EXPIRED, or FAILED |
curl 'https://api.bongluy.com/payment?merchantStoreId=branch-2&status=SUCCESS&per_page=50' \
-H 'authorization: Bearer sk_live_...'{
"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.
| Field | Type | Meaning |
|---|---|---|
data | array | Payment objects, newest first. Each one is abbreviated above but complete in the real response. |
page | number | The page you are on, echoing the page query. |
per_page | number | How many records this page can hold. Note the snake_case. |
total | number | Total matching payments across every page. |
total_pages | number | 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.
if (payment.deeplink) {
const isAndroid = /android/i.test(navigator.userAgent);
const href = isAndroid
? payment.deeplink.android
: payment.deeplink.scheme;
}qrString as authoritative.deeplink is null whenever qrString is null, and it is always null for Bakong payments. Guard it before choosing a platform URL.
Hosted checkout uses two clocks
The public payment routes return pollUntil andexpireAt. Count down to pollUntil. On ABA,POST /public/payment/:id/retry can reopen status checks while the QR remains payable. On Bakong both clocks are the same, so retry can re-check status but cannot extend the dead QR.
x-checkout-key added by your checkout server. Never expose that shared credential in browser code.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.
/payment/status201Read the current payment state.
scope payment:readSend 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"}'{
"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
- The provider transaction reference. The key is absent, not null, while pending.
- Field
receipt- Type
- string
- Meaning
- Provider receipt when available, named receiptUrl on the payment object. Absent while pending.
| Field | Type | Meaning |
|---|---|---|
paymentId | string | Bongluy's UUID for the payment. Named id on every other route. |
status | string | PENDING, SUCCESS, EXPIRED, or FAILED. |
expireAt | number | Epoch milliseconds, not the ISO string the payment object uses. |
at | number | Epoch milliseconds when this reading was taken. Useful for ordering poll results. |
settledTranId | string | The provider transaction reference. The key is absent, not null, while pending. |
receipt | string | Provider receipt when available, named receiptUrl on the payment object. Absent while pending. |
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.
Created and awaiting payment.
Paid and settled.
Expired without payment.
Reserved for a future non-success terminal state.
Bound your own wait, not the payment verdict
Poll every two or three seconds until status leavesPENDING or your checkout stops waiting. There is no streaming or long-polling endpoint.
async function waitForPayment({ merchantStoreId, tranId, giveUpAfterMs = 10 * 60_000 }) {
const deadline = Date.now() + giveUpAfterMs;
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));
}
// Still undecided, not unpaid. Reconcile later.
return "PENDING";
}EXPIRED status. Bakong performs one final provider check at its QR deadline and remainsPENDING if that check cannot get an answer. Do not release goods, but do not ask the payer to pay twice either; reconcile until the server reports a terminal state.At 2.5-second intervals, one three-minute polling session 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.
/payment/detail201Read the complete durable payment record.
scope payment:readThe 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.
{
"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.
| Field | Type | Meaning |
|---|---|---|
statusCode | number | Repeats the HTTP status, so you can read it without inspecting the response object. |
message | string | string[] | One sentence for most failures; an array of field complaints for validation. Branch on the type before displaying it. |
error | string | 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 or server failure
| Status | Meaning |
|---|---|
400 | Validation failure, or a store update with no recognized changes |
401 | Missing, invalid, expired, revoked, or under-scoped API key |
403 | Unrecognized origin on a public route |
404 | Missing or foreign store/payment |
409 | merchantStoreId already in use |
429 | Rate limit exceeded |
500 | PayWay failure or server failure |
401 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.
Receive terminal results without waiting.
A store webhook receives one POST when a payment reaches SUCCESS or EXPIRED. Configure a secret to sign it; polling remains available alongside it.
Turn on deliveries per store
Set webhookUrl and webhookSecret when creating a store or under changes onPOST /stores/update. Mint the secret yourself withopenssl rand -hex 32 and keep it like a password. It is write-only: losing it means replacing it.
curl -X POST https://api.bongluy.com/stores/update \
-H 'authorization: Bearer sk_live_...' \
-H 'content-type: application/json' \
-d '{"merchantStoreId":"branch-2","changes":{"webhookUrl":"https://yourshop.com/hooks/bongluy","webhookSecret":"whsec_..."}}'X-Signature header. Use https in production and always configure a secret.The payload is deliberately small
A PENDING payment produces no webhook. Exactly one delivery is queued when either rail reachesSUCCESS or EXPIRED; retries can redeliver that same body. Optional fields are absent rather than null.
{
"paymentId": "8435481a-48a8-4bb2-91d2-bcd1e604fb17",
"tranId": "INV-1042",
"status": "SUCCESS",
"settledTranId": "aba-tran-1",
"receipt": "https://...",
"at": 1786763501992
}- Field
paymentId- Type
- string
- Always
- Yes
- Notes
- Bongluy's payment UUID
- Field
status- Type
- SUCCESS | EXPIRED
- Always
- Yes
- Notes
- Never PENDING
- Field
at- Type
- number
- Always
- Yes
- Notes
- Epoch milliseconds
- Field
tranId- Type
- string
- Always
- No
- Notes
- Your sale or order id, exactly as supplied at creation
- Field
settledTranId- Type
- string
- Always
- No
- Notes
- The bank's settlement reference
- Field
receipt- Type
- string
- Always
- No
- Notes
- ABA receipt URL; absent on Bakong
| Field | Type | Always | Notes |
|---|---|---|---|
paymentId | string | Yes | Bongluy's payment UUID |
status | SUCCESS | EXPIRED | Yes | Never PENDING |
at | number | Yes | Epoch milliseconds |
tranId | string | No | Your sale or order id, exactly as supplied at creation |
settledTranId | string | No | The bank's settlement reference |
receipt | string | No | ABA receipt URL; absent on Bakong |
tranId is your order identifier and the useful fulfilment key. settledTranId belongs to the bank and is intended for statement reconciliation. They are not interchangeable.Verify the raw body before parsing
X-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request bytes, keyed by the store's secret. Do not hash re-serialized JSON. In Express, mountexpress.raw on this route before any JSON parser and compare equal-length buffers with timingSafeEqual.
Secret rotation applies to the next attempt, including a retry already in flight. There is no overlap where both secrets work, so rotate during quiet traffic when possible.
Respond first, then fulfil once
Return any 2xx within 10 seconds. Every other status, connection failure, or timeout is retried 10 times with exponential backoff from roughly 2 seconds through 512 seconds, spanning about 17 minutes. After that, the delivery is abandoned without a delivery log or manual replay.
- Acknowledge
- Return 2xx before slow fulfilment work.
- Deduplicate
- Make repeated arrivals a no-op keyed by paymentId or a per-store unique tranId.
- Reconcile
- Use POST /payment/status or POST /payment/detail for an expected result that never arrives.
- Coordinate
- If polling wins the race, the later webhook must use the same idempotency key and do no work twice.
Exercise both terminal paths
There is no inspector, test-fire action, or replay. Point the store URL at a local tunnel such as cloudflared orngrok, then take a small Bakong payment. Its short QR lifetime makes both SUCCESS andEXPIRED practical to test.