Skip to content

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 ​

bash
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 ​

json
{
  "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.)

fieldtypealways presentMeaning
bank_codestring✅3-digit code · this is the value you send as sender.bank_code when creating an order
name_thstring✅Thai display name
name_enstring✅English display name
availableboolean✅🔴 false = not accepting new orders, grey the option out
message_thstring❌A reason you may show the user · optional means the key may be absent entirely, not null — your code must handle a missing key
sidestring[]✅Which side this bank can be used on — "sender" = usable as a sending account · "receiver" = usable as a destination

message_th really is optional

js
// ❌ 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.

js
// 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
// 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

  1. POST /v1/pay/deposits using that bank as the sending account → 409 sender_bank_unavailable
  2. available in GET /v1/pay/banks flips to false

🔴 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 ​

json
{
  "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"]
    }
  }
}
detailsWhat to do with it
public_message_thShow 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_secondsHow long until it is expected back — use it to decide between "try again later" and "pick another bank"
js
// 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: falsereasons: ['BANK_DISABLED']
Where you see itGET /v1/pay/banksdeposit.under_review after a slip upload
WhenBefore the order is createdAfter the user transferred and uploaded a slip
Effect on the orderCannot be created → 409Order goes to manual review and a human checks it
The moneyNo money involved yetAlready transferred 🔴 not lost, and never auto-rejected
Your jobGrey the option out in the formShow 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 side down to banks usable as a sending account
  • [ ] available: false → greyed out, not hidden · shows message_th when present
  • [ ] Code handles a missing message_th key (not just null)
  • [ ] 5-minute cache with fallback to the previous value when a refresh fails
  • [ ] 409 sender_bank_unavailable moves the user to alternative_banks[] instead of a bare error
  • [ ] No job cancels open orders when a bank closes

Next ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01