Destination accounts
Implementation 0092 — validation and deployment pending
The implemented Live P2P profile accepts existing verified beneficiaries only. Public beneficiary creation, lookup and verification APIs, and the Customer API, are outside this profile. Your provider must confirm deployment readiness before real payments.
Eligible accounts in this profile
Have your provider prepare an account owned by the same Merchant in the same Live environment as the key. It must be verified, accept new orders, and pass current identity and receiving-bank checks. Another Merchant's account or a Test account cannot be substituted.
Send beneficiary_id as ben_ plus exactly 32 lowercase UUID hexadecimal characters:
{ "beneficiary_id": "ben_92000000000040008000000000000423" }This example demonstrates the format, not an account ready for use. Use the actual prepared account's ID; do not guess an ID or send a hyphenated UUID. This payout request does not accept a bank account number or inline beneficiary object. end_user_ref remains your reference; it does not create or verify a beneficiary.
An ineligible beneficiary returns 409 beneficiary_not_verified from POST /v1/pay/withdrawals without creating a withdrawal or reserving additional funds. Resolve the account configuration before a new request. See supported withdrawal creation.
Target contract — public beneficiary registration and verification, outside implementation 0092
The following flow describes future integration scope
This Live P2P profile does not enable the registration endpoints or beneficiary webhooks below. Do not assume those APIs are available and chain the example directly into payout creation.
The bank account that receives a withdrawal must be verified with the bank before you can use it on a withdrawal order.
Other gateways let you type the account number into the payout call. We do not.
Most gateways accept the destination account number together with the payout instruction, then discover later that the number was wrong.
We split it into two steps: register the account → wait for the bank's verification result → then create the withdrawal.
What you have to build: one extra "manage payout accounts" screen, separate from the withdrawal screen, with a waiting state while verification is outstanding (normally under 30 seconds). Do not write code that registers a beneficiary and immediately fires the withdrawal in the same request — that returns 409 every time.
Why this exists
In this system money does not leave a house account. One payer transfers directly into the bank account you name. Once that transfer happens it cannot be recalled, because it is a transfer between two real people's accounts.
| Without verification | What happens |
|---|---|
| One digit wrong in the account number | The money really does land in a stranger's account. There is no cancel button; each case has to be chased through the bank. |
| Account name does not match the owner | That is a money-laundering channel, and it breaks the terms banks impose on payment providers. |
| Account closed or frozen | The payer's transfer fails, that chunk stalls, and the withdrawal never completes. |
And because your money is locked the second a withdrawal is created, allowing an order against an account nobody has checked means your money sits locked against a destination that may not exist. So the system requires verification to pass before anything is locked.
The full flow, four steps
1 POST /v1/pay/beneficiaries → 201 { beneficiary_id, status: "pending_verification" }
2 We verify the name with the bank (async) → webhook beneficiary.verified or beneficiary.rejected
3 GET /v1/pay/beneficiaries?end_user_ref=... → the verified accounts (for your dropdown)
4 POST /v1/pay/withdrawals with beneficiary_id → 2011 · Register the account
POST /v1/pay/beneficiaries| field | required | notes |
|---|---|---|
end_user_ref | ✅ | the user id in your system · the account belongs to this user |
bank_code | ✅ | 3 digits, from GET /v1/pay/banks |
account_number | ✅ | digits only — the client must strip - and spaces before sending |
account_name_th | ✅ | 🔴 no honorific prefix (นาย / นาง / น.ส. / คุณ — Mr / Mrs / Ms / Khun) |
promptpay_id | — | if you have it |
bank_code is the Thai three-digit bank code — 014 in the example below is Siam Commercial Bank. Pull the authoritative list from GET /v1/pay/banks; do not hard-code it.
curl
curl -sS -X POST 'https://api.example-wl.com/v1/pay/beneficiaries' \
-H 'Authorization: Bearer sk_live_9f2c1ab6e0d74f' \
-H 'PG-Timestamp: 1755744902' \
-H 'PG-Nonce: 01K3PA2N4Q6S8U0W2Y4A6C8E0G' \
-H 'PG-Version: 2026-08-01' \
-H 'Idempotency-Key: ben-u88213-014-1234' \
-H 'PG-Signature: v1=8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b' \
-H 'Content-Type: application/json' \
-d '{
"end_user_ref": "u_88213",
"bank_code": "014",
"account_number": "4051234567",
"account_name_th": "สมชาย ใจดี",
"promptpay_id": "0812345678"
}'Response 201
{
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"status": "pending_verification"
}A 201 here is not yet usable for a withdrawal
pending_verification means "request accepted, we are asking the bank". Use this beneficiary_id on a withdrawal right now and you get 409 beneficiary_not_verified — with no money locked and no order created.
Wait for the beneficiary.verified webhook (normally under 30 seconds), then enable the withdraw button for that account.
Node (fetch)
import { pgFetch } from './pg-client.js'
export async function registerBeneficiary({ userId, bankCode, accountNumber, accountNameTh, promptpayId }) {
const res = await pgFetch('POST', '/v1/pay/beneficiaries', {
idempotencyKey: `ben-${userId}-${bankCode}-${accountNumber.slice(-4)}`,
body: {
end_user_ref: userId,
bank_code: bankCode,
account_number: accountNumber.replace(/\D/g, ''), // strip dashes and spaces
account_name_th: accountNameTh.trim(),
...(promptpayId ? { promptpay_id: promptpayId } : {}),
},
})
if (res.status !== 201) throw new PgError(res.body.error)
await db.none(
`INSERT INTO pg_beneficiaries (beneficiary_id, end_user_ref, status)
VALUES ($1,$2,$3) ON CONFLICT (beneficiary_id) DO NOTHING`,
[res.body.beneficiary_id, userId, res.body.status]
)
return res.body // status = 'pending_verification' → the screen must show a waiting state
}PHP
<?php
function register_beneficiary(string $userId, string $bankCode, string $accountNumber, string $nameTh, ?string $promptpayId = null): array
{
$payload = [
'end_user_ref' => $userId,
'bank_code' => $bankCode,
'account_number' => preg_replace('/\D/', '', $accountNumber),
'account_name_th' => trim($nameTh),
];
if ($promptpayId !== null) {
$payload['promptpay_id'] = $promptpayId;
}
$idemKey = 'ben-' . $userId . '-' . $bankCode . '-' . substr($accountNumber, -4);
[$status, $body] = pg_request('POST', '/v1/pay/beneficiaries', $payload, $idemKey);
if ($status !== 201) {
throw new PgError($body['error']);
}
return $body; // ['beneficiary_id' => ..., 'status' => 'pending_verification']
}2 · The verification result arrives by webhook
We verify the account name with the bank asynchronously and then send one of two events.
| event | meaning | what to do |
|---|---|---|
beneficiary.verified | the bank confirmed the account exists and the name matches | enable withdrawals for this account |
beneficiary.rejected | unusable · read code | let the user fix it and register again |
code values on beneficiary.rejected
code | meaning | message to show |
|---|---|---|
NAME_MISMATCH | the name entered does not match the account holder on file at the bank | "ชื่อบัญชีไม่ตรงกับที่ธนาคารบันทึกไว้ ตรวจสอบการสะกดอีกครั้ง" (The account name does not match the bank's records. Check the spelling.) |
ACCOUNT_NOT_FOUND | that account number does not exist at that bank | "ไม่พบเลขบัญชีนี้ ตรวจสอบเลขบัญชีและธนาคาร" (Account number not found. Check the number and the bank.) |
BANK_UNAVAILABLE | the bank could not run the check right now | "ธนาคารนี้ตรวจสอบไม่ได้ชั่วคราว ลองใหม่อีกครั้งภายหลัง" (This bank cannot be checked at the moment. Try again later.) — 🔴 this one is not the user's fault. Give them a retry button, not a blank form. |
{
"id": "evt_01K2WZ6V9R4N8QT2SM0HYDXKPC",
"object": "event",
"type": "beneficiary.verified",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-20T08:14:07.226Z",
"sequence": 90114,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"data": {
"object": {
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"status": "verified",
"end_user_ref": "u_88213",
"bank_code": "014"
},
"previous_attributes": { "status": "pending_verification" }
}
}Your parser must tolerate unknown fields
The contract pins the event names and the code set, but it does not freeze the full field list of this object. Read beneficiary_id, status and — on rejection — code, and let every other field pass through. Never throw on an unknown key.
// inside the central webhook handler, after the signature has been verified
case 'beneficiary.verified':
await db.query(
`UPDATE pg_beneficiaries SET status = 'verified' WHERE beneficiary_id = $1`,
[event.data.object.beneficiary_id]
)
await notifyUserWithdrawalReady(event.data.object.end_user_ref)
break
case 'beneficiary.rejected':
await db.query(
`UPDATE pg_beneficiaries SET status = 'rejected', reject_code = $2 WHERE beneficiary_id = $1`,
[event.data.object.beneficiary_id, event.data.object.code]
)
break3 · List the accounts that are ready
GET /v1/pay/beneficiaries?end_user_ref=u_88213Returns only accounts that are already verified, so you can bind it straight to a dropdown without filtering it yourself.
Let users pick, not type
Every time a user types a new account number you get another chance at a typo and another verification wait. The withdrawal screen should be a dropdown of verified accounts plus a small "add a new account" link that goes to the registration screen.
4 · Use it on a withdrawal
{
"amount_minor": 100000,
"currency": "THB",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC"
}You may send an inline beneficiary{} object instead of beneficiary_id, but that account must already have passed verification. Otherwise:
{
"error": {
"code": "beneficiary_not_verified",
"type": "invalid_request_error",
"message": "Beneficiary account has not completed verification.",
"message_th": "บัญชีผู้รับเงินยังไม่ผ่านการยืนยัน",
"retryable": true,
"retry_after_seconds": null,
"request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6"
}
}🔴 On this 409 no order is created and no money is locked. If you locked the user's balance in your own system before firing the request, release it immediately.
Checklist for your side
- [ ] A
pg_beneficiariestable holdingbeneficiary_id,end_user_ref,status,reject_code - [ ] An add-account screen, separate from the withdrawal screen, with a "verifying with the bank" state
- [ ] Handlers for
beneficiary.verifiedandbeneficiary.rejected - [ ] The withdrawal screen uses a dropdown from
GET /v1/pay/beneficiaries, not a free-text account field - [ ] The withdraw button stays disabled until the user has at least one
verifiedaccount - [ ]
BANK_UNAVAILABLEoffers a "try again" button instead of resetting the whole form
Next
| If you are about to | Go to |
|---|---|
| Create your first withdrawal | Create a withdrawal |
| Show the net amount before the user confirms | Quote the fee first |
| Write the withdrawal webhook handler | Handling partial fills |
| Get webhook signature verification passing | Verifying Ed25519 signatures |
| Read the rules for the payer's account (a different topic) | Sender account rules |
