สร้างรายการถอน
ขอบเขตปัจจุบัน — implementation 0092 รอตรวจและ deploy
เริ่มจาก Live P2P API ที่พัฒนาแล้ว ผู้ให้บริการต้องยืนยันรุ่นที่ deploy และการตั้งค่าของคุณก่อนใช้งานเงินจริง ตัวอย่างในหัวข้อ Target contract ด้านล่างยังไม่ใช่สิ่งที่รุ่นนี้เปิดครบ
คำขอใน implementation 0092
ใช้ POST /v1/pay/withdrawals กับ Live key ที่ได้รับ withdrawals:write และ HMAC เดิม ต้องมี Idempotency-Key 8–200 ตัวอักษร เมื่อ retry ใช้ body/key เดิม แต่ nonce และลายเซ็นใหม่
{
"amount_minor": 100000,
"currency": "THB",
"merchant_reference": "WD-20261017-0001",
"end_user_ref": "customer-001",
"beneficiary_id": "ben_92000000000040008000000000000423",
"metadata": { "order": "WD-20261017-0001" }
}amount_minor เป็นจำนวนเต็มบวกหน่วยสตางค์ ไม่เกิน 9007199254740991 และต้องผ่านยอดขั้นต่ำตามนโยบาย P2P ปัจจุบัน reference ทั้งสองยาว 1–64 ตัวอักษร ไม่มี control characters; merchant_reference ไม่ซ้ำต่อ Merchant metadata รับ string ไม่เกิน 20 keys ชื่อ key ยาว 1–40 ตัวอักษร ค่าไม่เกิน 500 ตัวอักษร ข้อมูล client ที่รองรับจะตรวจรูปแบบแล้วไม่เก็บใน metadata/response
ใช้เฉพาะ beneficiary ที่มีอยู่และยืนยันแล้ว รูปแบบ ben_ + UUID hex พิมพ์เล็ก 32 ตัว ของ Merchant/Live mode เดียวกัน รุ่นนี้ไม่รับ inline beneficiary และยังไม่มี Customer/beneficiary registration API หรือ Merchant quote ในขอบเขตนี้
| ผลตอบกลับ | ความหมายในรุ่นนี้ |
|---|---|
201 | pending_approval, requires_approval: true, version: 0 กัน principal + fee แล้ว และรอพนักงานอนุมัติสองคนก่อน matching |
402 insufficient_balance | available ไม่พอสำหรับ principal + fee ไม่สร้างรายการ |
409 beneficiary_not_verified | บัญชีไม่พร้อมหรือไม่ใช่ของ Merchant/Live mode นี้ |
422 amount_below_p2p_minimum | จำนวนต่ำกว่านโยบาย P2P ที่ใช้อยู่ |
ตรึงราคาขาย Payout ทั้งสี่ source ตอนสร้าง โดยฐาน fee ปัจจุบันใช้ wl_pool อ่าน fee_total_minor และ total_locked_minor จาก response 201 ยังไม่ใช่เงินถึงผู้รับ ให้ติดตาม GET /v1/pay/withdrawals/{id} ด้วย withdrawals:read จน status: "settled" และ remaining_minor: 0 event รุ่นนี้เป็น business snapshot แบบเดิม ไม่ใช่ Withdrawal เต็มใบ ดู endpoint, pagination และ event ที่รองรับ
Target contract — flow ขยายที่ implementation 0092 ยังไม่เปิดครบ
ตัวอย่างส่วนนี้เป็นเป้าหมายการเชื่อมต่อ
inline beneficiary, การลงทะเบียนบัญชีผ่าน Public API, quote และชุด event เต็มด้านล่าง ต้องอ้างอิง ขอบเขตที่พัฒนาแล้ว ก่อนนำไปใช้
รายการถอนคือการสั่งให้ระบบหา ผู้ฝาก มาโอนเงินเข้าบัญชีปลายทางที่คุณระบุ เงินไม่ได้ออกจากบัญชีกลางของเรา — ผู้ฝากโอนตรงเข้าบัญชีนั้นทีละก้อน
สองอย่างที่ต้องมีก่อน มิฉะนั้นสร้างไม่ได้เลย:
| ต้องมี | ไม่มีแล้วเกิดอะไร |
|---|---|
บัญชีปลายทางที่ status = 'verified' แล้ว | 409 beneficiary_not_verified · ไม่มี order ถูกสร้าง ไม่มีการล็อกเงิน |
available_minor ≥ ยอดถอน + ค่าธรรมเนียม | 402 insufficient_balance · ไม่มี order ถูกสร้าง |
ค่าธรรมเนียมของเรา "บวกเพิ่ม" ไม่ใช่ "หักจากยอดถอน"
gateway หลายเจ้าในตลาดหักค่าธรรมเนียมออกจากยอดถอน — ผู้ใช้สั่งถอน 1,000 ได้รับจริง 973.25
ของเราไม่ใช่แบบนั้น ผู้ใช้สั่งถอน 1,000 ก็ได้รับ 1,000 เต็ม แล้วค่าธรรมเนียมถูก บวกเพิ่ม หักจากกระเป๋าของคุณต่างหาก ⇒ ยอดที่ถูกล็อกจริงคือ total_locked_minor = amount_total_minor + fee_total_minor
สิ่งที่คุณต้องทำเพิ่ม: เช็กยอดในระบบของคุณด้วยตัวเลข ยอดถอน + ค่าธรรมเนียม ตั้งแต่หน้าจอ ไม่ใช่แค่ยอดถอน · เรียก /v1/pay/quote ก่อนเปิดฟอร์มเสมอ
Endpoint
POST /v1/pay/withdrawalsHeader ที่บังคับ
| header | ค่า |
|---|---|
Authorization | Bearer sk_live_... |
PG-Signature | v1=<hex ของ HMAC-SHA256> — ดู การยืนยันตัวตนของ request |
PG-Timestamp | unix seconds (คลาดเคลื่อนได้ ±300 วิ) |
PG-Nonce | ไม่ซ้ำภายใน 10 นาที · เปลี่ยนทุก attempt |
Idempotency-Key | บังคับทุก POST · TTL ฝั่งเรา 7 วัน |
PG-Version | เช่น 2026-08-01 |
Idempotency-Key ต้องเขียนลง DB ก่อนยิง ไม่ใช่ gen ตอน retry
ถ้า retry แล้ว gen key ใหม่ คุณจะได้ รายการถอนสองใบ และเงินถูกล็อกสองเท่า · retry ทุกครั้งต้องอ่าน key เดิมจาก DB แล้วยิงซ้ำด้วย key เดิม (เปลี่ยนแค่ PG-Nonce)
Idempotency-Key อยู่ใน canonical string ของลายเซ็นด้วย — เปลี่ยน key แล้วไม่เซ็นใหม่ = 401
Request body
| field | บังคับ | ชนิด | หมายเหตุ |
|---|---|---|---|
amount_minor | ✅ | int > 0 | 🔴 หน่วยสตางค์ · 1,000.00 บาท = 100000 · ในตัว object ที่ตอบกลับมา field นี้ชื่อ amount_total_minor |
currency | ✅ | string | ปัจจุบัน "THB" เท่านั้น |
merchant_reference | ✅ | string ≤ 64 | unique ต่อ merchant |
end_user_ref | ✅ | string ≤ 64 | id ผู้ใช้ในระบบคุณ |
beneficiary_id | ✅ (หรือ beneficiary{}) | string | ได้จาก POST /v1/pay/beneficiaries · ต้อง verified แล้ว |
beneficiary | ✅ (หรือ beneficiary_id) | object | {bank_code, account_number, account_name_th, promptpay_id?} · ถ้าบัญชีนี้ยังไม่ผ่าน verify → 409 beneficiary_not_verified |
client | — | object | {device_fingerprint, ip, user_agent} |
metadata | — | object | ≤ 20 keys · ≤ 500 bytes ต่อค่า |
ใช้ beneficiary_id ไม่ใช่ beneficiary{}
beneficiary{} แบบกรอกสดผ่านได้ก็ต่อเมื่อบัญชีนั้น verify ผ่านมาก่อนแล้ว ⇒ ถ้าคุณส่งแบบกรอกสดโดยไม่เคย verify คุณจะได้ 409 ทุกครั้ง
flow ที่ถูกคือ: สร้าง beneficiary → รอ beneficiary.verified → ค่อยสร้างรายการถอนด้วย beneficiary_id
ตัวอย่างเต็ม
ถอน 5,000.00 บาท (500000) ค่าธรรมเนียม 90.95 บาท (9095) ⇒ ต้องมี available_minor ≥ 509095
curl
curl -sS -X POST 'https://api.example-wl.com/v1/pay/withdrawals' \
-H 'Authorization: Bearer sk_live_9f2c1ab6e0d74f' \
-H 'PG-Timestamp: 1755745331' \
-H 'PG-Nonce: 01K3PA5T8V0X2Z4B6D8F0H2J4L' \
-H 'PG-Version: 2026-08-01' \
-H 'Idempotency-Key: wdr-u88213-20260821-0001' \
-H 'PG-Signature: v1=6b1f0c9d8e7a4b2c5d3e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c' \
-H 'Content-Type: application/json' \
-d '{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "WD-20260821-0001",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)"
},
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0001" }
}'Node (fetch)
// pgFetch() = helper ที่เซ็น canonical string ให้ ดู /get-started/authentication
import { pgFetch } from './pg-client.js'
import { db } from './db.js'
export async function createWithdrawal({ userId, amountMinor, beneficiaryId, ref }) {
// 🔴 เขียน Idempotency-Key ลง DB ก่อนยิง — retry ทุกครั้งอ่านค่าเดิมกลับมาใช้
const key = await db.oneValue(
`INSERT INTO wd_requests (merchant_reference, idem_key)
VALUES ($1, $2) ON CONFLICT (merchant_reference) DO UPDATE SET merchant_reference = EXCLUDED.merchant_reference
RETURNING idem_key`,
[ref, `wdr-${userId}-${ref}`]
)
const res = await pgFetch('POST', '/v1/pay/withdrawals', {
idempotencyKey: key,
body: {
amount_minor: amountMinor,
currency: 'THB',
merchant_reference: ref,
end_user_ref: userId,
beneficiary_id: beneficiaryId,
client: { device_fingerprint: 'df_9a1c4e77b2', ip: '203.0.113.44', user_agent: 'Mozilla/5.0 ...' },
metadata: { user_id: userId, ticket: ref },
},
})
if (res.status === 402) {
const d = res.body.error.details
// 🔴 ไม่มี order ถูกสร้าง → ปลดล็อกยอดในระบบคุณคืนผู้ใช้ "ทันที"
await unlockUserBalance(userId, ref)
throw new NotEnoughBalance(d.available_minor, d.required_minor, d.pending_clearing_available_at)
}
if (res.status === 409 && res.body.error.code === 'beneficiary_not_verified') {
await unlockUserBalance(userId, ref)
throw new BeneficiaryNotReady()
}
if (res.status !== 201) throw new PgError(res.body.error)
const w = res.body
await db.none(
`INSERT INTO pg_withdrawals (pg_withdrawal_id, pg_version, status,
amount_total_minor, fee_total_minor, total_locked_minor,
filled_minor, remaining_minor, our_ref)
VALUES ($1,$2,$3,$4,$5,$6,0,$4,$7)
ON CONFLICT (pg_withdrawal_id) DO NOTHING`,
[w.id, w.version, w.status, w.amount_total_minor, w.fee_total_minor, w.total_locked_minor, ref]
)
return w
}PHP
<?php
// pg_request() = helper ที่เซ็น canonical string ให้ ดู /get-started/authentication
function create_withdrawal(PDO $db, string $userId, int $amountMinor, string $beneficiaryId, string $ref): array
{
// 🔴 เขียน Idempotency-Key ลง DB ก่อนยิง
$st = $db->prepare(
'INSERT INTO wd_requests (merchant_reference, idem_key) VALUES (:r, :k)
ON CONFLICT (merchant_reference) DO UPDATE SET merchant_reference = EXCLUDED.merchant_reference
RETURNING idem_key'
);
$st->execute([':r' => $ref, ':k' => "wdr-{$userId}-{$ref}"]);
$idemKey = $st->fetchColumn();
[$status, $body] = pg_request('POST', '/v1/pay/withdrawals', [
'amount_minor' => $amountMinor,
'currency' => 'THB',
'merchant_reference' => $ref,
'end_user_ref' => $userId,
'beneficiary_id' => $beneficiaryId,
'client' => [
'device_fingerprint' => 'df_9a1c4e77b2',
'ip' => '203.0.113.44',
'user_agent' => 'Mozilla/5.0 ...',
],
'metadata' => ['user_id' => $userId, 'ticket' => $ref],
], $idemKey);
if ($status === 402) {
unlock_user_balance($userId, $ref); // ไม่มี order ถูกสร้าง
throw new NotEnoughBalance($body['error']['details']);
}
if ($status === 409 && $body['error']['code'] === 'beneficiary_not_verified') {
unlock_user_balance($userId, $ref);
throw new BeneficiaryNotReady();
}
if ($status !== 201) {
throw new PgError($body['error']);
}
return $body;
}Response 201
{
"id": "wdr_01K3PA6M2T7QW9YB4H0NRSXC5D",
"object": "withdrawal_order",
"version": 1,
"status": "open",
"merchant_reference": "WD-20260821-0001",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 500000,
"fee_total_minor": 9095,
"total_locked_minor": 509095,
"filled_minor": 0,
"remaining_minor": 500000,
"fee_recognized_minor": 0,
"settled_splits": 0,
"max_splits": 4,
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0001" }
}| field | อ่านยังไง |
|---|---|
status | open = เข้า pool แล้ว · pending_approval = รอ back-office อนุมัติก่อน |
amount_total_minor | ยอดที่ผู้ใช้ปลายทางจะได้รับครบเมื่อ settled |
fee_total_minor | ค่าธรรมเนียมที่ ล็อกไว้ตั้งแต่ตอนสร้าง และไม่เปลี่ยนอีก 🔴 ยึดค่านี้ ไม่ใช่ค่าจาก quote |
total_locked_minor | amount_total_minor + fee_total_minor — ยอดที่หายจาก available_minor ทันที |
remaining_minor | ยอดที่ยังหาผู้โอนไม่ได้ · ตอนสร้าง = amount_total_minor |
max_splits | จำนวนก้อนสูงสุดที่ใบนี้ถูกซอยได้ · เป็นค่านโยบาย อย่า hardcode |
expires_at | 🔴 พ้นเวลานี้ใบหลุดจาก pool · อ่านจาก response เสมอ ห้าม hardcode countdown |
parser ต้องทนกับ field ที่ไม่รู้จัก
api_version เพิ่ม field ใหม่ได้โดยไม่นับเป็น breaking change และ enum หลายตัวเป็น open enum (fee_breakdown[].component, balance_transaction.type, cancel_reason_code) ⇒ ทุก switch ต้องมี default: และ parser ห้าม throw เมื่อเจอ key ที่ไม่รู้จัก
เกิดอะไรขึ้นทันทีที่ 201
- เงินถูกหักจาก
available_minorเท่ากับtotal_locked_minorตั้งแต่วินาทีนี้ ไม่ใช่ตอนจ่ายจริง - webhook
withdrawal.created(มีrequires_approvalและreserved_balance_transaction_id) - webhook
balance.debited—type: "withdrawal_reserved"🔴 event นี้ปิดการรับไม่ได้ ต้องมี handler ตั้งแต่วันแรก
{
"id": "evt_01K3PA6M4V8ZR2NTY0KDXC7WH5",
"object": "event",
"type": "withdrawal.created",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T03:02:11.508Z",
"sequence": 91004,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"request": { "id": "req_01K3PA6M1S", "idempotency_key": "wdr-u88213-20260821-0001" },
"data": {
"object": {
"id": "wdr_01K3PA6M2T7QW9YB4H0NRSXC5D",
"object": "withdrawal_order",
"version": 1,
"status": "open",
"merchant_reference": "WD-20260821-0001",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 500000,
"fee_total_minor": 9095,
"total_locked_minor": 509095,
"filled_minor": 0,
"remaining_minor": 500000,
"fee_recognized_minor": 0,
"settled_splits": 0,
"max_splits": 4,
"requires_approval": false,
"reserved_balance_transaction_id": "btxn_01K3PA6M3U9YT1QW5HN0ZDXR8C",
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0001" }
},
"previous_attributes": null
}
}201 ไม่ได้แปลว่าเงินถึงผู้ใช้ปลายทาง
201 แปลว่า "ล็อกเงินคุณแล้ว และเอาใบนี้เข้า pool แล้ว" เท่านั้น · เงินจริงจะเข้าบัญชีปลายทางเป็นก้อน ๆ เมื่อมีผู้ฝากโอนให้
ปิดรายการในระบบคุณได้เมื่อ status = 'settled' และ remaining_minor = 0 เท่านั้น — อ่าน รับ fill หลายก้อน ก่อนเขียน handler
pending_approval — เมื่อใบนี้ต้องอนุมัติก่อน
ถ้านโยบายของผู้ให้บริการกำหนดให้ยอดระดับนี้ต้องอนุมัติก่อน คุณจะได้ status: "pending_approval" แทน open
| ข้อเท็จจริง | ผล |
|---|---|
เงินถูกล็อกไปแล้วตั้งแต่ 201 | ยอด available_minor ลดแล้วแม้ยังไม่อนุมัติ |
| ใบนี้ ยังไม่เข้า pool | ยังไม่มีใครโอนให้ |
| แก้ยอด / แก้บัญชีปลายทาง ไม่ได้ | ต้องยกเลิกแล้วสร้างใหม่ |
| ยกเลิกได้ คืนเต็มจำนวน | ดู ยกเลิกรายการถอน |
| ไม่อนุมัติภายใน 24 ชม. | ระบบยกเลิกให้เอง (cancel_reason_code: "approval_ttl_expired") คืนเต็มจำนวน |
การอนุมัติทำผ่าน POST /v1/bo/withdrawals/{id}/approve ซึ่งเป็น endpoint ฝั่ง back-office ไม่ใช่ส่วนหนึ่งของการเชื่อมต่อฝั่ง merchant
ตอนอนุมัติไม่มี webhook แจ้ง
pending_approval → open ไม่มี event เฉพาะของตัวเอง — คุณอ่าน status ได้จาก data.object.status ของ event ถัดไปเท่านั้น (ซึ่งใบแรกมักเป็น withdrawal.matched)
⇒ ถ้าหน้าจอต้องแสดงว่า "อนุมัติแล้ว" ให้ poll GET /v1/pay/withdrawals/{id} แบบ backoff 3→5→10→30 วินาที อย่า poll ทุกวินาที (pay_read = 1,200 ครั้ง/นาที ต่อ api_key)
Error ทั้งหมดของ endpoint นี้
| HTTP | code | เกิดเมื่อ | retry ได้ | ต้องทำอะไร |
|---|---|---|---|---|
| 400 | idempotency_key_required | ไม่ส่ง Idempotency-Key | ❌ | เพิ่ม header (เขียนลง DB ก่อนยิง) |
| 400 | malformed_json / unsupported_media_type | body พัง / content-type ผิด | ❌ | แก้โค้ด |
| 401 | signature_invalid | canonical string ผิด | ❌ | เทียบกับ test vector |
| 401 | timestamp_out_of_window | PG-Timestamp เกิน ±300 วิ | ✅ หลัง sync นาฬิกา | ตั้ง NTP |
| 401 | nonce_replayed | PG-Nonce ซ้ำใน 10 นาที | ❌ | gen nonce ใหม่ทุก attempt (คง Idempotency-Key เดิม) |
| 401 | api_key_invalid / api_key_revoked | key ผิด/ถูกเพิกถอน | ❌ | ติดต่อผู้ให้บริการของคุณ |
| 402 | insufficient_balance | available_minor < amount + fee · 🔴 ไม่มี order ถูกสร้าง | ✅ หลัง pending_clearing_available_at | ปลดล็อกยอดผู้ใช้คืนทันที + บอกเวลาที่ถอนได้ |
| 403 | tenant_suspended / scope_denied | บัญชีถูกระงับ / key ไม่มีสิทธิ์ | ❌ | ติดต่อผู้ให้บริการของคุณ |
| 409 | idempotency_key_reused | key เดิมแต่ body ต่าง | ❌ | เปลี่ยน body = ต้องเปลี่ยน key |
| 409 | idempotency_in_flight | request แรกที่ใช้ key นี้ยังไม่จบ | ✅ (retry_after_seconds: 1) | รอแล้ว retry ด้วย key เดิม |
| 409 | beneficiary_not_verified | บัญชีปลายทางยังไม่ผ่าน verify · 🔴 ไม่มี order ไม่มีการล็อกเงิน | ✅ หลัง verify ผ่าน | รอ webhook beneficiary.verified |
| 422 | validation_error | field ผิดรูป | ❌ | ดู details.fields[] |
| 422 | amount_not_supported | ยอดต่ำ/สูงกว่านโยบาย หรือมีเศษที่ไม่รองรับ | ❌ | ใช้ details.min_minor / details.max_minor |
| 429 | rate_limited | ชน bucket pay_write (600/60 วิ ต่อ api_key) | ✅ | เคารพ Retry-After + jitter ±20% · 🔴 ห้าม retry เป็นลูป |
| 503 | route_unavailable / service_unavailable | ระบบกำลัง shed โหลด | ✅ | แสดง "ระบบหนาแน่น ลองใหม่" ตาม Retry-After |
| 500 | internal_error | ฝั่งเราพัง | ✅ | ส่ง request_id ให้ support |
402 insufficient_balance — เคสที่พังเงียบที่สุด
{
"error": {
"code": "insufficient_balance",
"type": "invalid_request_error",
"message": "Available balance is lower than amount plus fee.",
"message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6",
"details": {
"available_minor": 502000,
"required_minor": 509095,
"pending_clearing_minor": 480000,
"pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
}
}
}dashboard มีเงิน แต่สร้างรายการถอนไม่ได้
ยอดที่เพิ่งเข้าจากรายการฝากอยู่ใน bucket pending_clearing นาน 72 ชั่วโมง ก่อนย้ายไป available · ถอนได้จาก available_minor เท่านั้น
โค้ดที่คำนวณ "เงินที่ถอนได้" จาก credited_amount_minor สะสม จะสร้างรายการถอนไม่ผ่านทั้งที่หน้าจอโชว์ว่ามีเงิน
สิ่งที่ต้องทำเพิ่ม: แยกสองยอดในระบบคุณ — แสดงผลใช้ available + pending_clearing แต่คำนวณสิทธิ์ถอนใช้ available เท่านั้น · ดึงจาก GET /v1/pay/balance · เมื่อได้ 402 ให้ ปลดล็อกยอดผู้ใช้คืนทันที แล้วบอกเวลาจาก pending_clearing_available_at
หลังจากนี้จะมี webhook อะไรตามมา
withdrawal.created 1 ใบ ล็อกเงินแล้ว
balance.debited 1 ใบ type = withdrawal_reserved 🔴 ปิดไม่ได้
withdrawal.matched n ใบ ทุกครั้งที่มีผู้ฝากถูกจองให้ใบนี้ (ยังไม่ใช่เงินเข้า)
withdrawal.filled n ใบ ต่อ 1 ก้อนที่โอนถึงจริงและตรวจสลิปผ่าน
withdrawal.settled 1 ใบ filled_minor = amount_total_minorwithdrawal.matched ไม่ใช่เงินเข้า
matched แปลว่า "จองผู้ฝากที่จะโอนให้แล้ว" เท่านั้น ผู้ฝากอาจไม่โอน อาจโอนช้า อาจโอนแล้วสลิปไม่ผ่าน
และเพราะการตรวจสลิปฝั่งผู้ฝากเป็นแบบ รับไว้ก่อนแล้วตรวจทีหลัง (durable intake สำเร็จตอบ 202 และไม่มี 409 duplicate_slip แบบทันที) ⇒ ผลตรวจจึงมาถึงคุณช้ากว่าจังหวะที่ผู้ฝากกดส่ง และในกรณีที่ตรวจย้อนหลังพบว่าสลิปใช้ไม่ได้ ก้อนที่เคย filled แล้วยัง ถูกกลับรายการได้ ผ่าน withdrawal.fill_reversed (event นี้ก็ปิดไม่ได้)
ขั้นต่อไป
| ถ้าคุณกำลังจะ | ไปที่ |
|---|---|
| แสดงยอดสุทธิให้ผู้ใช้เห็นก่อนกดยืนยัน | คำนวณค่าธรรมเนียมล่วงหน้า |
| เตรียมบัญชีปลายทางให้ผ่าน verify | บัญชีปลายทาง |
| เขียน webhook handler ของรายการถอน | รับ fill หลายก้อน |
| ยกเลิกส่วนที่ยังไม่ถูกเติม | ยกเลิกรายการถอน |
| กัน event เก่ามาทับของใหม่ | ลำดับไม่การันตี — version guard |
| ตรวจลายเซ็นก่อนแตะ payload | ตรวจลายเซ็น Ed25519 |
