User choices
Start with what cannot be chosen
The user cannot pick the destination account, and there is no endpoint that would let them
The destination is another withdrawing user's bank account, assigned by the matching engine. It is not a gateway account and it is not a list to choose from. Matching happens during POST /v1/pay/deposits and the result arrives once, in legs[].
⇒ Do not build a screen that lets the user "choose the destination bank" or "choose which account to pay into". Do not offer a "get a new QR" button in the hope of a different destination — that is just creating a duplicate order.
If you are migrating from a gateway where the user picked a payment method (bank transfer / PromptPay / over-the-counter), that screen has to go. What replaces it is the sender account picker, which is a completely different thing.
The trap: multiple legs[] is not a choice
legs.length > 1 means the user must transfer several times, not pick one.
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"status": "awaiting_payment",
"amount_minor": 500000,
"leg_count": 2,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 300000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
},
{
"leg_ref": "leg_7QF2H8KN",
"sequence": 2,
"amount_minor": 200000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/2.png",
"receiver": {
"bank_code": "004",
"bank_name_th": "ธนาคารกสิกรไทย",
"account_number_masked": "xxx-x-x9887",
"account_name_masked": "ว*** ป***"
}
}
]
}THB 3,000 + THB 2,000 = THB 5,000. The user has to complete both transfers.
| ❌ Don't | ✅ Do |
|---|---|
Render only legs[0] | Render every leg in sequence order |
| Let the user choose which leg to pay | Say "step 1 of 2" and walk them through one at a time |
Use the order's amount_minor as the QR amount | Use each leg's own legs[].amount_minor |
Use only the order-level expires_at | Each leg has its own expires_at — use the one on the leg you are showing |
Never translate leg_status into a money status
leg_status is only pending or complete. It is fine for a progress bar, but 🔴 complete does not mean the money arrived. The only thing that means the money arrived is the order's status = "settled".
leg_ref is likewise an internal reference for one fill line, not an order number to show the user.
Four real choice points
1. Suggested amounts while waiting for liquidity
With a 202 awaiting_liquidity we also send liquidity.suggested_amounts_minor[] — amounts that can be matched right now.
{
"id": "dep_01K3M8B2VQ7HYD4NKR0XF9TW6E",
"status": "awaiting_liquidity",
"amount_minor": 500000,
"legs": [],
"liquidity": {
"wait_started_at": "2026-08-21T04:10:47.902Z",
"estimated_wait_ms": 25000,
"retry_interval_ms": 10000,
"suggested_amounts_minor": [300000, 400000, 1000000]
},
"retry_after_seconds": 10
}The screen to show:
กำลังจัดหาช่องทางโอนสำหรับ 5,000.00 บาท
โดยทั่วไปใช้เวลาไม่เกินครึ่งนาที
ถ้าไม่อยากรอ ยอดเหล่านี้เริ่มโอนได้ทันที
[ 3,000.00 ] [ 4,000.00 ] [ 10,000.00 ]English gloss: "Finding a transfer route for THB 5,000.00 / This usually takes under half a minute / If you would rather not wait, these amounts can be transferred right away."
Changing the amount = cancel the old order, create a new one
The amount of an existing order cannot be edited. If the user picks a suggested amount:
POST /v1/pay/deposits/{id}/cancelon the old orderPOST /v1/pay/depositsfor the new amount with a newIdempotency-Key— when the body changes, the key must change, otherwise you get409 idempotency_key_reused
🔴 Do not create the new order without cancelling the old one, or you hit the open-order cap and get 409 too_many_open_deposits.
// Node — the user picked a suggested amount
async function switchToSuggestedAmount(deposit, newAmountMinor, order) {
await cancelDeposit(deposit.id, 'user_changed_amount')
return createDeposit({
...order,
amountMinor: newAmountMinor,
ref: `${order.ref}-R2`, // merchant_reference must be unique
idempotencyKey: `topup-${order.ref}-R2`, // 🔴 always a new key
})
}2. The sender account
This is the user's most important choice and the only one that directly affects slip verification. It has its own page: the sender account.
Once the order exists, the only way to change the sender account is cancel and recreate, and only while no slip has been uploaded.
3. Which routes you accept — fill_preference
This one is chosen by the merchant, not the user. Send it in POST /v1/pay/deposits.
| Value | Effect |
|---|---|
"any" (default) | The system uses whichever route matches fastest · the returned liquidity_source may be internal or external_gateway |
"internal_only" | Restricts matching to internal liquidity ⇒ a higher chance of 202 awaiting_liquidity and longer waits, but liquidity_source is always internal |
How to choose
Use "any" unless you have a specific reason not to. "internal_only" fits merchants whose internal policy fixes the route, at the cost of landing on the 202 path more often.
liquidity_source has exactly two values, internal and external_gateway. It is reconciliation information for you — not something to show the end user — and it has no effect on how you credit.
4. The payment page — checkout_url or your own
Every matched response includes a checkout_url.
Use checkout_url | Build your own | |
|---|---|---|
| Work involved | A redirect | QR + countdown + sender-account warning + multi-leg flow, all yours |
qr_payload | Never touched | You have to render it 🔴 never log it, never store it |
| Data-leak exposure | Lowest | Yours to own |
| Brand control | Your provider's page | Complete |
We recommend checkout_url as the default, especially for a first integration. Build your own page once you are ready to own the countdown read from the real expires_at, the sender-account warning box, and the multi-leg screens.
One more setting that is not the user's — min_ttl_seconds
Sent at creation time. If a match exists but the route's remaining time is shorter than this value, the system looks for another route or stays on awaiting_liquidity.
Use it when your users have to switch to a banking app, which takes time. Setting min_ttl_seconds to a realistic transfer duration beats handing them a QR that expires in 90 seconds.
Accepted values are 0–86,400 seconds and must fit within the clamped order lifetime; otherwise creation returns 422.
{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "ORD-9985",
"end_user_ref": "u_88213",
"sender_id": "snd_01K2QF7V8H3NPD0YXW4RJ6ZB5C",
"fill_preference": "any",
"min_ttl_seconds": 300,
"client": {
"device_fingerprint": "df_9a1c7f2b0e",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
}
}🔴 Whatever you set min_ttl_seconds to, the countdown must always read the expires_at you actually received. This value is only a matching condition, not a guarantee of time.
Summary table
| User's choice? | What | How |
|---|---|---|
| ✅ | Sender account | Pick from GET /v1/pay/senders before creating the order · changing it later = cancel + recreate |
| ✅ | Amount (while waiting for liquidity) | Pick from liquidity.suggested_amounts_minor[] = cancel + recreate |
| ⚠️ | Payment page | Your choice, not the user's — checkout_url or your own |
| ⚠️ | Liquidity route | Your choice via fill_preference, not the user's |
| ❌ | Destination account/bank | Matched by the system; there is no endpoint to pick one |
| ❌ | Which leg to pay | All legs must be paid, in sequence order |
| ❌ | The amount of an existing order | Not editable; cancel + recreate |
| ❌ | Extending expires_at | Not possible · but late slips are still accepted after expiry |
Next
- The sender account — the only choice that directly changes slip verification
- Create a deposit —
fill_preference,min_ttl_seconds, and the full202path - Cancel a deposit — step one of every "change of mind"
- Deposit status — how
leg_statusdiffers fromstatus
