Bank list
GET /v1/pay/banks is the only source for the bank list used in your sender-account form.
Never hardcode the bank list
Banks are opened and closed for new orders at any time (maintenance windows, temporary incidents, risk policy). If you hardcode the list, a user will pick a closed bank, type in their full account number, and hit 409 sender_bank_unavailable on the confirm button.
That is the single highest-abandonment point in a top-up flow — because by then they have already spent the effort.
Request
curl https://api.example-wl.com/v1/pay/banks \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749400" \
-H "PG-Nonce: 01JQA5TG4T6V8X0Z2B4D6F8H0K" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=0d5e...71fa"Response
{
"data": [
{ "bank_code": "002", "name_th": "ธนาคารกรุงเทพ", "name_en": "Bangkok Bank", "available": true, "side": ["sender", "receiver"] },
{ "bank_code": "004", "name_th": "ธนาคารกสิกรไทย", "name_en": "Kasikornbank", "available": true, "side": ["sender", "receiver"] },
{ "bank_code": "006", "name_th": "ธนาคารกรุงไทย", "name_en": "Krungthai Bank", "available": true, "side": ["sender", "receiver"] },
{ "bank_code": "011", "name_th": "ธนาคารทหารไทยธนชาต", "name_en": "TMBThanachart Bank", "available": true, "side": ["sender", "receiver"] },
{ "bank_code": "014", "name_th": "ธนาคารไทยพาณิชย์", "name_en": "Siam Commercial Bank", "available": true, "side": ["sender", "receiver"] },
{ "bank_code": "025", "name_th": "ธนาคารกรุงศรีอยุธยา", "name_en": "Bank of Ayudhya", "available": false, "message_th": "ธนาคารปิดปรับปรุงระบบชั่วคราว กรุณาเลือกธนาคารอื่น", "side": ["sender", "receiver"] },
{ "bank_code": "030", "name_th": "ธนาคารออมสิน", "name_en": "Government Savings Bank", "available": true, "side": ["sender"] },
{ "bank_code": "034", "name_th": "ธนาคารเพื่อการเกษตรและสหกรณ์การเกษตร", "name_en": "Bank for Agriculture and Agricultural Cooperatives", "available": true, "side": ["sender"] },
{ "bank_code": "069", "name_th": "ธนาคารเกียรตินาคินภัทร", "name_en": "Kiatnakin Phatra Bank", "available": true, "side": ["sender", "receiver"] },
{ "bank_code": "022", "name_th": "ธนาคารซีไอเอ็มบี ไทย", "name_en": "CIMB Thai Bank", "available": false, "message_th": "ธนาคารนี้ปิดรับรายการใหม่ชั่วคราว", "side": ["sender"] }
]
}(The list above illustrates the shape. Always fetch the real values from the endpoint.)
| field | type | always present | Meaning |
|---|---|---|---|
bank_code | string | ✅ | 3-digit code · this is the value you send as sender.bank_code when creating an order |
name_th | string | ✅ | Thai display name |
name_en | string | ✅ | English display name |
available | boolean | ✅ | 🔴 false = not accepting new orders, grey the option out |
message_th | string | ❌ | A reason you may show the user · optional means the key may be absent entirely, not null — your code must handle a missing key |
side | string[] | ✅ | Which side this bank can be used on — "sender" = usable as a sending account · "receiver" = usable as a destination |
message_th really is optional
// ❌ throws when the key is missing
const reason = bank.message_th.trim()
// ✅ fallback text: "this bank is temporarily not accepting new orders"
const reason = bank.message_th ?? 'ธนาคารนี้ปิดรับรายการใหม่ชั่วคราว'How to use it
1. Filter by side first
The sender-account form must only show banks whose side contains "sender". Banks without it cannot be used as a sending account at all — do not list them.
2. Grey out with available — do not hide
Grey, not hidden
If you hide closed banks, a user whose only account is at that bank cannot find it, assumes your system is broken, and calls support.
If you grey it out with a message, they understand instantly and pick another route themselves.
คุณจะโอนจากบัญชีไหน
( ) ธนาคารกรุงเทพ
( ) ธนาคารกสิกรไทย
( ) ธนาคารกรุงไทย
( ) ธนาคารไทยพาณิชย์
(·) ธนาคารกรุงศรีอยุธยา ← เทา กดไม่ได้
ธนาคารปิดปรับปรุงระบบชั่วคราว กรุณาเลือกธนาคารอื่นEnglish gloss: "Which account will you transfer from? / Bangkok Bank / Kasikornbank / Krungthai Bank / Siam Commercial Bank / Bank of Ayudhya ← greyed out, not selectable / The bank is temporarily down for maintenance. Please choose another bank."
3. Cache it, but refresh it
This list can and should be cached — refreshing every ~5 minutes is enough. The bucket for this endpoint is very wide (3,000/min per key), but there is no reason to call it on every request.
// Node — 5-minute cache, falling back to the previous value if a refresh fails
let cache = { at: 0, banks: [] }
const TTL_MS = 5 * 60 * 1000
async function getBanks() {
if (Date.now() - cache.at < TTL_MS && cache.banks.length) return cache.banks
try {
const res = await fetch('https://api.example-wl.com/v1/pay/banks', {
headers: sign('GET', '/v1/pay/banks', '', '', null),
})
const { data } = await res.json()
cache = { at: Date.now(), banks: data }
} catch (err) {
// 🔴 If the refresh fails but you still hold an old copy, keep using it — better than an empty form
if (!cache.banks.length) throw err
}
return cache.banks
}
async function senderBankOptions() {
const banks = await getBanks()
return banks
.filter((b) => b.side.includes('sender'))
.map((b) => ({
value: b.bank_code,
label: b.name_th,
disabled: b.available !== true,
// fallback: "this bank is temporarily not accepting new orders"
hint: b.available ? undefined : (b.message_th ?? 'ธนาคารนี้ปิดรับรายการใหม่ชั่วคราว'),
}))
}<?php
// PHP — cache to file/APCu, then keep only the sender side
function senderBankOptions(array $headers): array
{
$banks = apcu_fetch('pg_banks');
if ($banks === false) {
$res = httpGet('https://api.example-wl.com/v1/pay/banks', $headers);
$banks = $res['data'];
apcu_store('pg_banks', $banks, 300); // 5 minutes
}
$out = [];
foreach ($banks as $b) {
if (!in_array('sender', $b['side'], true)) {
continue;
}
$out[] = [
'value' => $b['bank_code'],
'label' => $b['name_th'],
'disabled' => ($b['available'] !== true),
// fallback: "this bank is temporarily not accepting new orders"
'hint' => $b['available']
? null
: ($b['message_th'] ?? 'ธนาคารนี้ปิดรับรายการใหม่ชั่วคราว'),
];
}
return $out;
}What actually changes when a bank is closed
Closing a bank affects exactly two things
POST /v1/pay/depositsusing that bank as the sending account →409 sender_bank_unavailableavailableinGET /v1/pay/banksflips tofalse
🔴 Not one existing order moves — no event, no new version, no automatic cancellation. And slips for existing orders are still accepted, even if the sending bank was closed a moment ago.
⇒ Do not write a job that cancels open orders when a bank closes. That shuts the door on users who are about to transfer.
Handling 409 sender_bank_unavailable
{
"error": {
"code": "sender_bank_unavailable",
"type": "invalid_request_error",
"message": "The sending bank is not accepting new deposits right now.",
"message_th": "ธนาคารต้นทางปิดรับรายการใหม่ชั่วคราว",
"retryable": true,
"retry_after_seconds": null,
"request_id": "req_01JQA5TH6U8W0Y2A4C6E8G0J2L",
"details": {
"bank_code": "025",
"retry_after_hint_seconds": 1800,
"public_message_th": "ธนาคารกรุงศรีอยุธยาปิดปรับปรุงระบบชั่วคราว กรุณาเลือกธนาคารอื่น",
"alternative_banks": ["002", "004", "014"]
}
}
}details | What to do with it |
|---|---|
public_message_th | Show it to the user as-is; you do not need to write your own copy |
alternative_banks[] | Move these banks to the top of the list so the user can continue immediately |
retry_after_hint_seconds | How long until it is expected back — use it to decide between "try again later" and "pick another bank" |
// Node — move the user forward instead of showing a dead-end error
catch (err) {
if (err.code === 'sender_bank_unavailable') {
await refreshBankCache() // the cached copy is stale
return ui.pickAnotherBank({
message: err.details.public_message_th,
highlight: err.details.alternative_banks, // pin these to the top
})
}
throw err
}available: false and BANK_DISABLED are different things
The names look alike, which causes confusion, but they happen at different points and need different handling.
available: false | reasons: ['BANK_DISABLED'] | |
|---|---|---|
| Where you see it | GET /v1/pay/banks | deposit.under_review after a slip upload |
| When | Before the order is created | After the user transferred and uploaded a slip |
| Effect on the order | Cannot be created → 409 | Order goes to manual review and a human checks it |
| The money | No money involved yet | Already transferred 🔴 not lost, and never auto-rejected |
| Your job | Grey the option out in the form | Show review.reasons[0].display_th · no "transfer again" button |
⇒ A bank with available: true can still land an order in the BANK_DISABLED queue, because automatic slip verification for that bank may be off independently of whether it accepts new orders. This is not an error and not a rejection of money.
Pre-production checklist
- [ ] The sender-account form reads the list from
GET /v1/pay/banks, not from a constant in code - [ ] Filtered by
sidedown to banks usable as a sending account - [ ]
available: false→ greyed out, not hidden · showsmessage_thwhen present - [ ] Code handles a missing
message_thkey (not justnull) - [ ] 5-minute cache with fallback to the previous value when a refresh fails
- [ ]
409 sender_bank_unavailablemoves the user toalternative_banks[]instead of a bare error - [ ] No job cancels open orders when a bank closes
Next
- The sender account — the form that consumes this list, and how to cut the review queue
- Create a deposit —
409 sender_bank_unavailableand every other error - Upload a slip — where
BANK_DISABLEDshows up - Deposit status — how to present
under_reviewso the user does not pay twice
