Skip to content

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:

json
{ "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 verificationWhat happens
One digit wrong in the account numberThe 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 ownerThat is a money-laundering channel, and it breaks the terms banks impose on payment providers.
Account closed or frozenThe 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  →  201

1 · Register the account ​

POST /v1/pay/beneficiaries
fieldrequirednotes
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 ​

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

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

js
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
<?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.

eventmeaningwhat to do
beneficiary.verifiedthe bank confirmed the account exists and the name matchesenable withdrawals for this account
beneficiary.rejectedunusable · read codelet the user fix it and register again

code values on beneficiary.rejected ​

codemeaningmessage to show
NAME_MISMATCHthe 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_FOUNDthat account number does not exist at that bank"ไม่พบเลขบัญชีนี้ ตรวจสอบเลขบัญชีและธนาคาร" (Account number not found. Check the number and the bank.)
BANK_UNAVAILABLEthe 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.
json
{
  "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.

js
// 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]
  )
  break

3 · List the accounts that are ready ​

GET /v1/pay/beneficiaries?end_user_ref=u_88213

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

json
{
  "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:

json
{
  "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_beneficiaries table holding beneficiary_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.verified and beneficiary.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 verified account
  • [ ] BANK_UNAVAILABLE offers a "try again" button instead of resetting the whole form

Next ​

If you are about toGo to
Create your first withdrawalCreate a withdrawal
Show the net amount before the user confirmsQuote the fee first
Write the withdrawal webhook handlerHandling partial fills
Get webhook signature verification passingVerifying Ed25519 signatures
Read the rules for the payer's account (a different topic)Sender account rules

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