Skip to content
Appearance

Two KHQR rails. One payment contract.

Create a payment from your server, render the returned KHQR, and observe the terminal result by webhook or polling. 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 with its required USD PayWay link, optional KHR link, and optional Bakong account. Each payment names the provider and currency while keeping the same identifiers, statuses, and reconciliation flow.

  1. Authenticatesk_live_...
  2. Register storePOST /stores
  3. Create paymentPOST /payment
  4. Present checkoutqrString
  5. Observe outcomewebhook + status

Your first payment

Register the payment rails you collect into, then create a payment with a decimal string, USD or KHR, a provider, 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=usd...","khrPaywayLink":"https://link.payway.com.kh/aba?id=khr...","bakongAccountId":"your_name@aclb"}'

# 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","currency":"USD","provider":"BAKONG","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
Credential
x-checkout-key: ...
Held by
Your checkout server
Access
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.

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

Because links are per store and currency, two payments created a second apart can use different currencies or settle into different bank accounts. Model branches or sellers as separate stores rather than swapping links 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=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.

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

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
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
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 selects ABA PayWay or Bakong, reserves a KHQR, and writes one durable payment record with the same status contract.

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
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
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"}'
Payment response
{
  "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.

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

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

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.

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.

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
if (payment.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, 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.

Public payment, status, and retry routes require anx-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.

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

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";
}
A local deadline is not an 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.

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 or server failure
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.

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.

Configure a store webhook
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_..."}}'
A URL without a secret receives unsigned requests with noX-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
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.