The sender account
This page decides whether your integration runs smoothly or drowns in a manual review queue. If you read only one page on this site, read this one.
The one rule
senderis "the account whose owner will actually press send", not "the owner of the account in your system".
If a husband transfers on his wife's behalf, sender is the husband's account. If the money comes from a company account, sender is the company account. The identity that will appear on the slip is the identity you must send.
Why the sender account is required
In the normal path this system has no central collection account. The money your user transfers does not go to a gateway account — it goes directly into the personal bank account of another user who is withdrawing.
That means one destination account can receive money from several people within seconds of each other. The system has to work out which order a newly uploaded slip belongs to.
| Approach | Who uses it | Problem |
|---|---|---|
| Different satang remainders (1,000.37 / 1,000.42) | Typical gateways in this market | Users mistype the amount constantly · at high volume the remainders collide with each other · the amount no longer matches the product price |
| The sender's identity | This system | The amount is always a round number that matches the price exactly · in exchange, the merchant must ask for the sender account before creating the order |
No sender account = no order
POST /v1/pay/deposits without both sender and sender_id returns 422 sender_required and no order is created. This is not a field you can fill in later.
What the system checks — six dimensions
Every uploaded slip is checked on six dimensions. All six make an exact payment. When the other five pass, a clean short-pay settles automatically at A=V<X, while a same-slip overpayment settles at the ordered amount with a claim. Only a real non-amount, ambiguity, or policy reason sends it to the manual queue.
| Dimension | field in verification{} | reasons[].code on failure |
|---|---|---|
| Amount matches exactly (no tolerance) | amount_exact | amount-only short-pay → settle automatically at actual value; overpay with the other five checks valid → settle at the order amount with a claim/flag; AMOUNT_MISMATCH may appear in review only alongside another real reason |
| Transfer time falls inside the order's window | within_time_window | TIME_WINDOW |
| Sending bank matches | sender_bank_match | SENDER_BANK_MISMATCH |
| Sending account number matches | sender_account_match | SENDER_ACCOUNT_MISMATCH |
| Sending account name matches | sender_name_match | SENDER_NAME_MISMATCH |
| Destination account matches | receiver_match | RECEIVER_MISMATCH |
verification{} comes back as six plain booleans. It never contains values read off the actual slip, because the real sender may not be the person you declared (money sent from a spouse's account) — those values are third-party personal data.
Three of the six dimensions are purely about the sender account ⇒ the quality of what you send at creation time directly sets your review-queue rate.
What happens when the user pays from a different account
Transfer from a different account
→ The slip is still accepted (money already transferred is never refused)
→ status = under_review, reasons = ['SENDER_ACCOUNT_MISMATCH']
→ A human reviews it
→ settled (the usual outcome) or rejected (if the money really is not for this order)What the system will never do
- ❌ Never auto-rejects because the account does not match
- ❌ Never auto-approves because an order has waited a long time (it becomes
review.escalated: trueinstead) - ❌ Never refuses a slip from a bank that was just closed for new orders (
BANK_DISABLED→ manual review)
What the merchant must never do
These three cost your users real money
- Never display "wrong account, your money will not arrive" — untrue. The money arrives once a reviewer confirms it belongs to this order.
- Never auto-refund or auto-cancel on
under_review— the order is still running, and it cannot be cancelled any more. - Never put a "transfer again" button on an
under_reviewscreen — the user will pay twice, and you will be chasing a refund.
The three screens you need
Screen 1 — before creating the order (fixes roughly 90% of the problem)
Let the user pick, not type.
คุณจะโอนจากบัญชีไหน
( ) กสิกรไทย xxx-x-x1234 สมชาย ใจดี
( ) ไทยพาณิชย์ xxx-x-x9876 สมชาย ใจดี
( ) + ใช้บัญชีอื่น
หมายเหตุ: ระบบจะจับคู่สลิปกับบัญชีนี้
ถ้าโอนจากบัญชีอื่น รายการจะต้องรอเจ้าหน้าที่ตรวจสอบEnglish gloss: "Which account will you transfer from? / Kasikornbank xxx-x-x1234 Somchai Jaidee / Siam Commercial Bank xxx-x-x9876 Somchai Jaidee / + use another account / Note: we match your slip against this account. If you transfer from a different account, the order has to wait for manual review."
The list comes from GET /v1/pay/senders?end_user_ref=; you send the chosen sender_id back when creating the order.
Screen 2 — the QR screen (this box must outrank the QR)
┌──────────────────────────────────────────────┐
│ โอนจากบัญชีนี้เท่านั้น │
│ │
│ กสิกรไทย xxx-x-x1234 │
│ สมชาย ใจดี │
│ │
│ ถ้าจะใช้บัญชีอื่น กด "เปลี่ยนบัญชีผู้โอน" │
│ ก่อนโอน (โอนไปแล้วเปลี่ยนไม่ได้) │
│ │
│ [ เปลี่ยนบัญชีผู้โอน ] │
│ │
│ QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้ │
│ ห้ามบันทึกภาพไว้ใช้ครั้งหน้า │
└──────────────────────────────────────────────┘English gloss: "Transfer from this account only / Kasikornbank xxx-x-x1234 / Somchai Jaidee / To use a different account, tap 'Change sending account' before you transfer (it cannot be changed afterwards) / [ Change sending account ] / This QR is valid once, for this order only. Do not save the image for next time."
The "เปลี่ยนบัญชีผู้โอน" (Change sending account) button works like this:
POST /v1/pay/deposits/{id}/cancel— only possible while no slip has been uploaded- Create a new order with the new
senderand a newIdempotency-Key(body changed ⇒ key must change)
Giving the user an exit before they transfer is many times cheaper than letting the order fall into the review queue.
Screen 3 — after the transfer, account did not match
เราได้รับสลิปของคุณแล้ว
บัญชีที่โอนไม่ตรงกับที่แจ้งไว้ เจ้าหน้าที่กำลังตรวจสอบ
เงินของคุณไม่หาย และไม่ต้องโอนซ้ำEnglish gloss: "We have received your slip. The account you transferred from does not match the one you registered, so our team is reviewing it. Your money is safe and you do not need to transfer again."
Use the copy we send you — do not write your own
Pull the real text from review.reasons[0].display_th in the deposit.under_review webhook. We always write all three sentences: (1) we received your slip, (2) what it is waiting on, (3) your money is safe, do not transfer again.
If you want to state a duration, use review.estimated_resolution_bucket (under_15m · under_1h · under_4h · extended) and nothing else. 🔴 Never hardcode "within 15 minutes" — review time belongs to each provider.
The box must be yellow or blue, not red. This is a waiting state, not an error.
Thai UI copy you can ship as-is
Copy these directly; adjust brand names to yours. They stay in Thai because your end users are Thai.
| Where | Copy (Thai) | English gloss |
|---|---|---|
| Under the sender-account picker | ระบบจะจับคู่สลิปกับบัญชีนี้ ถ้าโอนจากบัญชีอื่น รายการจะต้องรอเจ้าหน้าที่ตรวจสอบ | We match your slip against this account. Transferring from another account sends the order to manual review. |
| Box heading on the QR screen | โอนจากบัญชีนี้เท่านั้น | Transfer from this account only |
| Under the box on the QR screen | ถ้าจะใช้บัญชีอื่น กดเปลี่ยนบัญชีผู้โอนก่อนโอน โอนไปแล้วเปลี่ยนไม่ได้ | To use another account, change the sending account before you transfer. It cannot be changed afterwards. |
| Under the QR | QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้ ห้ามบันทึกภาพไว้ใช้ครั้งหน้า | This QR is valid once, for this order only. Do not save the image for next time. |
| Confirm button before transferring | ฉันจะโอนจากบัญชี กสิกรไทย xxx-x-x1234 สมชาย ใจดี | I will transfer from Kasikornbank xxx-x-x1234, Somchai Jaidee |
After slip upload (slip_processing) | กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที | Checking your slip with the bank. This takes under a minute. |
Account mismatch (under_review) | เราได้รับสลิปของคุณแล้ว บัญชีที่โอนไม่ตรงกับที่แจ้งไว้ เจ้าหน้าที่กำลังตรวจสอบ เงินของคุณไม่หาย และไม่ต้องโอนซ้ำ | We received your slip. The sending account does not match the one registered. Our team is reviewing it. Your money is safe; do not transfer again. |
Name mismatch (under_review) | เราได้รับสลิปของคุณแล้ว ชื่อบัญชีผู้โอนไม่ตรงกับที่แจ้งไว้ เจ้าหน้าที่กำลังตรวจสอบ เงินของคุณไม่หาย และไม่ต้องโอนซ้ำ | We received your slip. The sender name does not match the one registered. Our team is reviewing it. Your money is safe; do not transfer again. |
| Review screen — buttons that must not exist | ❌ โอนใหม่อีกครั้ง ❌ ยกเลิกรายการ ❌ ขอคืนเงิน | ❌ Transfer again ❌ Cancel order ❌ Request refund |
| New-account form, under the name field | กรอกชื่อตามที่ปรากฏบนสลิป ไม่ต้องใส่คำนำหน้า เช่น นาย นาง น.ส. | Enter the name as printed on the slip. No honorifics such as Mr., Mrs., Ms. |
| Bank closed for new orders | ธนาคารนี้ปิดรับรายการใหม่ชั่วคราว กรุณาเลือกธนาคารอื่น | This bank is temporarily not accepting new orders. Please choose another bank. |
Cut the review queue with GET /v1/pay/senders
This endpoint returns the accounts this user has already transferred from successfully ⇒ the user types nothing.
curl -G https://api.example-wl.com/v1/pay/senders \
--data-urlencode "end_user_ref=u_88213" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L7" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=4b18...c07a"{
"data": [
{
"sender_id": "snd_01K2QF7V8H3NPD0YXW4RJ6ZB5C",
"bank_code": "004",
"bank_name_th": "ธนาคารกสิกรไทย",
"account_number_masked": "xxx-x-x1234",
"account_name_th_masked": "ส*** ใ***",
"last_used_at": "2026-08-18T11:42:07.315Z",
"auto_approved_count": 7
},
{
"sender_id": "snd_01K2QF7V8H3NPD0YXW4RJ6ZB6D",
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x9876",
"account_name_th_masked": "ส*** ใ***",
"last_used_at": "2026-07-30T08:15:52.004Z",
"auto_approved_count": 2
}
]
}This endpoint minimizes unnecessary account-data storage
We return a sender_id, never the full account number ⇒ you never have to store full account numbers at all. Store the sender_id plus the masked values you display.
// Node — most recently used account first, then let the user pick
async function senderChoices(endUserRef) {
const path = '/v1/pay/senders'
const query = `end_user_ref=${encodeURIComponent(endUserRef)}`
const res = await fetch(`https://api.example-wl.com${path}?${query}`, {
headers: sign('GET', path, query, '', null),
})
const { data } = await res.json()
return data
.sort((a, b) => Date.parse(b.last_used_at) - Date.parse(a.last_used_at))
.map((s) => ({
value: s.sender_id, // send this back when creating the order
label: `${s.bank_name_th} ${s.account_number_masked} ${s.account_name_th_masked}`,
familiar: s.auto_approved_count > 0, // has been auto-approved before
}))
}New users who have to type it in — validate client-side first
These four steps visibly cut your review-queue rate.
| Do this | Why |
|---|---|
Strip - and spaces from account_number so only digits remain | Banks report account numbers without separators |
Strip the honorifics นาย (Mr.) นาง (Mrs.) น.ส. / นางสาว (Ms.) คุณ (Khun) from account_name_th | 🔴 Banks do not print honorifics on slips — including one is an instant name mismatch |
| Collapse repeated spaces to one and trim both ends | Extra whitespace breaks the name match |
If the user types a Latin-script name into the Thai field, warn them and move it to account_name_en | The two fields are compared differently |
// Node — clean up before POST /v1/pay/deposits
const HONORIFICS = /^(นาย|นางสาว|น\.ส\.|นาง|คุณ|ด\.ช\.|ด\.ญ\.)\s*/
function cleanSender(input) {
const accountNumber = input.accountNumber.replace(/[^0-9]/g, '')
const nameTh = input.nameTh.replace(HONORIFICS, '').replace(/\s+/g, ' ').trim()
// "เลขบัญชีสั้นเกินไป" = "account number is too short"
if (accountNumber.length < 8) throw new Error('เลขบัญชีสั้นเกินไป')
if (/^[A-Za-z\s.]+$/.test(nameTh)) {
// "this field must be a Thai name; if the slip shows a Latin name, use the English name field"
throw new Error('ช่องนี้ต้องเป็นชื่อภาษาไทย ถ้าสลิปแสดงชื่ออังกฤษให้กรอกในช่องชื่ออังกฤษ')
}
return {
bank_code: input.bankCode, // must come from GET /v1/pay/banks with available = true
account_number: accountNumber,
account_name_th: nameTh,
...(input.nameEn ? { account_name_en: input.nameEn.toUpperCase().trim() } : {}),
}
}<?php
// PHP — same logic
function cleanSender(array $in): array
{
$account = preg_replace('/[^0-9]/', '', $in['account_number']);
$nameTh = preg_replace('/^(นาย|นางสาว|น\.ส\.|นาง|คุณ|ด\.ช\.|ด\.ญ\.)\s*/u', '', $in['account_name_th']);
$nameTh = trim(preg_replace('/\s+/u', ' ', $nameTh));
if (strlen($account) < 8) {
// "เลขบัญชีสั้นเกินไป" = "account number is too short"
throw new InvalidArgumentException('เลขบัญชีสั้นเกินไป');
}
$out = [
'bank_code' => $in['bank_code'],
'account_number' => $account,
'account_name_th' => $nameTh,
];
if (!empty($in['account_name_en'])) {
$out['account_name_en'] = trim(mb_strtoupper($in['account_name_en']));
}
return $out;
}🔴 The bank list in that form must be greyed out according to GET /v1/pay/banks. Otherwise the user hits 409 sender_bank_unavailable at the final step, which is where abandonment peaks.
Third-party payer cases
| Case | The correct approach | Result |
|---|---|---|
| Husband transfers for his wife | Send sender = the husband's account (the person pressing send), not the wife's account that owns the user profile | Automatic settled |
| Someone else transfers, not declared upfront | Not transferred yet → cancel and create a new order · already transferred → upload the slip as usual | The second path goes to under_review and a reviewer approves it |
| Transfer from a company account | account_name_th = the legal entity name exactly as it appears on the slip, e.g. บริษัท เอบีซี จำกัด (ABC Co., Ltd.), not a director's name · also send account_name_en if the slip shows a Latin name | The first attempt may go to under_review because banks truncate names differently · once it passes, the account appears in /v1/pay/senders and every later transfer is automatic |
| Regular B2B customer | Have them make one small top-up first to "register" the account | This is the onboarding pattern we recommend |
Third-party transfer policy
If your policy forbids third-party funds, enforce it in your own UI — for example, only list accounts whose name matches the profile owner. Do not expect the gateway to refuse for you: we never refuse money that has already been transferred, we send it to manual review instead.
Compared with gateways you have integrated before
| Your previous gateway | Here | |
|---|---|---|
| Order identified by | Satang remainder / reference in the memo | The sender's identity |
| Amount the user sees | 1,000.37 | 1,000.00 |
| Sender account required at creation | No | Yes, otherwise 422 sender_required |
| Paying from another account | Usually undetected; you open a ticket yourself | Goes to manual review automatically and mostly ends as settled |
| Extra screens needed | — | A sender-account picker + the warning box on the QR screen |
You only really add two things: a sender-account picker before creating the order, and a "transfer from this account only" box on the QR screen. In exchange the amount matches the product price exactly and satang remainders never collide.
Next
- Create a deposit — where
sender/sender_idactually goes - Available banks — greying out banks in the sender-account form
- Upload a slip — the step right after the user transfers
- Deposit status — what
under_reviewmeans and what you must not do during it - Cancel a deposit — what sits behind the "change sending account" button
