อนุมัติรายการถอน
รายการถอนบางใบเข้าสถานะ pending_approval ก่อน ต้องมีคนกดอนุมัติในระบบหลังบ้านถึงจะเข้าสู่กระบวนการจับคู่
เงินถูกล็อกตั้งแต่ตอน สร้าง ไม่ใช่ตอนอนุมัติ
POST /v1/pay/withdrawals หักยอด amount + fee ออกจาก available_minor ทันที ทั้งเส้นที่ต้องอนุมัติและไม่ต้อง คุณจะได้ balance.debited (type: "withdrawal_reserved") ตั้งแต่วินาทีที่สร้าง — ก่อนที่ใครจะกดอนุมัติ
เหตุผล: ถ้าล็อกตอนอนุมัติ ผู้ใช้จะสร้างใบถอนพร้อมกัน 10 ใบด้วยเงินก้อนเดียวได้ แล้วพอ approve ทีละใบจะติดลบ
รายการไหนต้องอนุมัติ
ดูจาก response ของการสร้าง
curl -sS -X POST "https://api.example-wl.com/v1/pay/withdrawals" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: wdr-u88213-20260821-01" \
-H "PG-Signature: v1=8f2c1ab6e0d74f9b3c5a7e1d0f6b8a4c2e9d7b5a3c1f0e8d6b4a2c0e8f6d4b2a" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 1000000,
"currency": "THB",
"merchant_reference": "WD-20260821-0042",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2R7Q4M8N0PZ3XT5VKD9WY2C",
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0"
},
"metadata": { "user_id": "u_88213" }
}'{
"id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
"object": "withdrawal_order",
"version": 1,
"status": "pending_approval",
"amount_total_minor": 1000000,
"fee_total_minor": 3000,
"total_locked_minor": 1003000,
"currency": "THB",
"expires_at": "2026-08-21T16:12:00.000Z",
"created_at": "2026-08-21T04:12:00.118Z"
}status ที่ได้ | แปลว่า | ต้องทำอะไรต่อ |
|---|---|---|
pending_approval | รอคนกดอนุมัติ | เข้าคิวในระบบหลังบ้าน |
open | ไม่ต้องอนุมัติ เข้าสู่การจับคู่แล้ว | รอ withdrawal.matched / withdrawal.filled |
webhook withdrawal.created มีฟิลด์ requires_approval และ reserved_balance_transaction_id (ใบที่กันยอด amount + fee ไว้)
total_locked_minor = ยอด + ค่าธรรมเนียม ไม่ใช่แค่ยอด
ค่าธรรมเนียมฝั่งถอนเป็นการ บวกเพิ่ม ไม่ใช่หักจากยอดที่ผู้รับได้ ถอน 10,000.00 ค่าธรรมเนียม 30.00 ⇒ ผู้รับได้ 10,000.00 เต็ม · กระเป๋าคุณลด 10,030.00 ถ้า available_minor ไม่ถึง 1003000 จะได้ 402 insufficient_balance และ ไม่มีใบถูกสร้าง ไม่มีการล็อกเงิน
อนุมัติ
curl -sS -X POST "https://api.example-wl.com/v1/bo/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC/approve" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
-H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
-H "Idempotency-Key: approve-wdr01K3PB2M7Q4RW8YT0NBVXH5DKC" \
-H "Content-Type: application/json" \
-d '{
"note": "ยอดตรงกับคำขอถอนของลูกค้าในระบบ ตรวจบัญชีปลายทางแล้ว",
"reason_code": "WITHDRAWAL_DETAILS_VERIFIED"
}'/v1/bo/* ใช้ OIDC access token ของเจ้าหน้าที่ ไม่ใช้ sk_live_* หรือ PG HMAC และ action นี้ต้องมี step-up token ที่ยังไม่หมดอายุ ระบบระบุตัวผู้อนุมัติจาก claim sub ใน OIDC token — ห้ามรับ actor_id หรือ approver id จาก request body
ขอบเขต idempotency รวม tenant + OIDC principal ที่ verify แล้ว (issuer/sub ที่ map เป็น tenant user) + livemode + method/path ดังนั้น retry จาก คนเดิม ด้วย literal key/body เดิม replay ผลเดิม แต่ผู้อนุมัติคนที่สองซึ่งเป็นคนละ principal จะใช้ literal key เดียวกันก็เป็นคนละ operation ได้ · step-up เป็น one-time credential ที่ bind กับ subject, resource/action, Idempotency-Key และ body digest; หลัง operation สำเร็จแล้ว การ replay ด้วย scope/key/body เดิมไม่ต้องทำ MFA ใหม่
{
"id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
"object": "withdrawal_order",
"version": 2,
"status": "open",
"amount_total_minor": 1000000,
"fee_total_minor": 3000,
"filled_minor": 0,
"remaining_minor": 1000000,
"approved_at": "2026-08-21T04:14:52.330Z",
"expires_at": "2026-08-21T16:12:00.000Z"
}ไม่มี event withdrawal.approved และจะไม่มี
การอนุมัติไม่ทำให้เงินขยับ (ล็อกไปตั้งแต่ตอนสร้างแล้ว) ⇒ ไม่มี journal ⇒ ไม่มี event เฉพาะ
คุณรู้ว่าใบถูกอนุมัติแล้วจาก data.object.status ที่เปลี่ยนเป็น open ใน event ถัดไปของใบนั้น (เช่น withdrawal.matched) หรือจาก GET /v1/pay/withdrawals/{id} โค้ดที่รอ event ชื่อ withdrawal.approved จะรอตลอดไป
เงื่อนไขที่ต้องผ่านครบก่อนอนุมัติได้
| # | เงื่อนไข | ทำไม |
|---|---|---|
| 1 | ผู้อนุมัติต้องไม่ใช่คนสร้างใบ | คนเดียวสร้างแล้วอนุมัติเองได้ = ไม่มีการควบคุมเลย |
| 2 | step-up MFA ภายใน 5 นาที ก่อนกด | เครื่องที่ล็อกอินค้างไว้ไม่พอสำหรับการจ่ายเงินออก |
| 3 | ผู้อนุมัติที่สองถ้ายอดเกินเพดาน (ดูหัวข้อถัดไป) | ยอดใหญ่ต้องมีคนที่สอง |
| 4 | บัญชีทุกชั้นในสายต้องยังไม่ถูกระงับ | ถ้าชั้นใดถูกระงับจะได้ 403 tenant_suspended |
| 5 | note มีความยาว อย่างน้อย 20 ตัวอักษร | ตรวจที่ฐานข้อมูล ไม่ผ่านได้ 422 review_note_required |
"อนุมัติแล้ว" ไม่ได้แปลว่า "จ่ายเงินแล้ว"
open แปลว่าใบเข้าไปอยู่ในกระบวนการจับคู่ ยังไม่มีใครโอนให้แม้แต่บาทเดียว ปิดรายการในระบบคุณได้เมื่อ status = 'settled' และ remaining_minor = 0 เท่านั้น
เพดานที่ต้องมีผู้อนุมัติสองคน
ยอดที่เกินเพดานที่ชั้นบนกำหนดไว้ ต้องมีผู้อนุมัติคนที่สองที่ ไม่ใช่คนแรก จึงจะเดินต่อได้
ผู้อนุมัติแต่ละคนต้องเรียก approve แยกคนละ request ด้วย OIDC access token และ one-time step-up token ของตัวเอง ห้ามส่งผู้อนุมัติสองคนมาใน request เดียว ทั้งสองคนใช้ literal Idempotency-Key เดียวกันได้เพราะ server scope ด้วย principal; ห้ามแก้ body เพื่อบังคับให้ request แยก
| ยอด | ต้องมีกี่คน | ผลของการกด approve ครั้งแรก |
|---|---|---|
| ไม่เกินเพดาน | 1 คน | status เปลี่ยนเป็น open ทันที |
| เกินเพดาน | 2 คน | ใบยังคง pending_approval จนกว่าคนที่สองจะกด |
สิ่งที่ระบบหลังบ้านของคุณต้องมี
- คิวที่แยก "รอคนแรก" กับ "รอคนที่สอง" ให้เห็น ไม่งั้นใบใหญ่จะค้างโดยไม่มีใครรู้
- ปุ่มอนุมัติต้องเทาสำหรับคนที่กดไปแล้ว — ไม่ใช่ปล่อยให้กดซ้ำแล้วค่อยขึ้น error
- นาฬิกา 24 ชั่วโมง ที่นับจากเวลาสร้าง (ดูหัวข้อถัดไป) เพราะใบที่รอคนที่สองก็หมดอายุเหมือนกัน
- บันทึกว่าใครกด กดเมื่อไร และ
noteว่าอะไร — ดู บันทึกและ audit log
ค่าเพดานไม่ได้ถูกส่งออกมาใน webhook
เราไม่ส่งตัวเลขเพดานออกไปกับ payload ของ event ⇒ อย่าพยายาม parse หาจาก event ดูค่าปัจจุบันจากหน้า ตั้งค่า และปรับได้ ภายในเพดานที่ชั้นบนกำหนด เท่านั้น
ปฏิเสธ
curl -sS -X POST "https://api.example-wl.com/v1/bo/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC/reject" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
-H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
-H "Idempotency-Key: reject-wdr01K3PB2M7Q4RW8YT0NBVXH5DKC" \
-H "Content-Type: application/json" \
-d '{
"note": "ลูกค้ายกเลิกคำขอถอนทางแชทก่อนเวลา 11:20 น. ยืนยันตัวตนแล้ว",
"reason_code": "END_USER_CANCELED"
}'ผลที่เกิด
- ใบไปที่
rejected— ทางเดียวที่เข้าสถานะนี้ได้คือถูกปฏิเสธตอนpending_approval - คืนเงิน เต็มจำนวน
amount + feeเข้าavailable_minor - webhook:
withdrawal.rejected(มีrejection.code+ ข้อความ) และbalance.credited(type: "withdrawal_released") - สร้างใบใหม่ได้ทันที ใบเดิมไม่กลับมา
note สั้นกว่า 20 ตัวอักษรจะได้
{ "error": {
"code": "review_note_required",
"type": "invalid_request_error",
"message": "A decision note of at least 20 characters is required.",
"message_th": "ต้องระบุเหตุผลอย่างน้อย 20 ตัวอักษร",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01JQA5TD5Y3A7E9G1J3L5N7Q9S",
"details": { "field": "note" } } }ใบที่ไม่มีใครกดภายใน 24 ชั่วโมง
ใบที่ค้าง pending_approval เกิน 24 ชั่วโมงจะถูกยกเลิกอัตโนมัติและคืนเงินเต็มจำนวน
{
"id": "evt_01K3RC7T2V5XZ8YB4KDMWQH0PL",
"object": "event",
"type": "withdrawal.canceled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-22T04:12:00.442Z",
"sequence": 90117,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
"object": "withdrawal_order",
"version": 3,
"status": "canceled",
"canceled_by": "system",
"cancel_reason_code": "approval_ttl_expired",
"amount_total_minor": 1000000,
"fee_total_minor": 3000,
"filled_minor": 0,
"refunded_minor": 1000000,
"fee_recognized_minor": 0,
"currency": "THB"
},
"previous_attributes": { "version": 1, "status": "pending_approval" }
}
}ตั้ง alert ก่อนถึง 24 ชั่วโมง
ใบที่ค้างเกิน 4 ชั่วโมงคือสัญญาณว่าคิวอนุมัติไม่มีคนดู ไม่ใช่ว่าใบมีปัญหา ลูกค้าที่รอถอน 24 ชั่วโมงแล้วโดนยกเลิกอัตโนมัติ จะไม่เข้าใจว่าเกิดอะไรขึ้นถ้าคุณไม่บอกเขาก่อน
ยกเลิกเองระหว่างรออนุมัติ
curl -sS -X POST "https://api.example-wl.com/v1/pay/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC/cancel" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749760" \
-H "PG-Nonce: 01JQA5TF7A5C9E1G3J5L7N9Q1S" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: cancel-wdr01K3PB2M7Q4RW8YT0NBVXH5DKC" \
-H "PG-Signature: v1=6d2b90f4a8c1e37b5d9f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b" \
-H "Content-Type: application/json" \
-d '{ "reason": "ลูกค้าเปลี่ยนบัญชีปลายทาง" }'คืนเงินเต็มจำนวน · withdrawal.canceled + balance.credited
แก้ยอดหรือแก้บัญชีปลายทางของใบที่ pending_approval ไม่ได้
ไม่มี endpoint สำหรับแก้ และจะไม่มี — ยกเลิกแล้วสร้างใบใหม่ เท่านั้น เหตุผล: เงินถูกล็อกด้วยยอดเดิมไปแล้ว การแก้ยอดกลางคันแปลว่าต้องปลดล็อกและล็อกใหม่ในทรานแซกชันเดียวกับการเปลี่ยนบัญชีปลายทาง ซึ่งเป็นจุดที่เงินหายได้จริง
หลังอนุมัติแล้วจะเจออะไรต่อ
1 ใบถอนรับเงินได้จาก หลายรายการฝาก ⇒ webhook มาหลายครั้ง ไม่ใช่ครั้งเดียวตอนจบ
withdrawal.matched (ก้อนที่ 1 · 4,000.00) ← ยิงทุกครั้งที่มีคนจับจอง ไม่ใช่ครั้งแรกครั้งเดียว
withdrawal.filled (filled 400000 · remaining 600000)
withdrawal.matched (ก้อนที่ 2 · 6,000.00)
withdrawal.filled (filled 1000000 · remaining 0)
withdrawal.settledwithdrawal.filled ไม่ใช่จุดจบ
อ่านยอดจาก data.object.filled_minor / remaining_minor ผ่าน version guard เสมอ ห้ามใช้ SUM() ของตาราง fill ฝั่งคุณมาขับ logic — event อาจยังมาไม่ครบ และลำดับไม่การันตี ตาราง fill มีไว้ทำ timeline เท่านั้น โดยใช้ leg_ref เป็น key
รายละเอียดเต็มอยู่ที่ หนึ่งใบรับได้หลายก้อน (partial fill)
Error ที่เจอบ่อยที่หน้านี้
| HTTP | code | เกิดเมื่อ | ทำอย่างไร |
|---|---|---|---|
| 402 | insufficient_balance | available_minor ไม่ถึง amount + fee ตอน สร้าง | ปลดล็อกยอดผู้ใช้คืนทันที · บอกเวลาด้วย details.pending_clearing_available_at |
| 409 | withdrawal_has_pending_fill | สั่งยกเลิกตอนที่มีคนกำลังโอนให้อยู่ | รอตาม retry_after_seconds แล้วลองใหม่ — 🔴 ห้ามยึดเงินคนที่กำลังโอน |
| 409 | withdrawal_not_cancelable | ใบ settled/expired/canceled ไปแล้ว | อ่านสถานะล่าสุดจาก GET |
| 409 | withdrawal_state_conflict | สถานะขยับไปแล้วระหว่างที่คุณกด | อ่านสถานะล่าสุดจาก GET แล้วตัดสินใจใหม่ |
| 409 | beneficiary_not_verified | บัญชีผู้รับยังไม่ผ่านการยืนยัน | รอ webhook beneficiary.verified — ไม่มีใบถูกสร้าง ไม่มีการล็อกเงิน |
| 422 | review_note_required | note สั้นกว่า 20 ตัวอักษร | เขียนเหตุผลให้ครบ |
| 403 | tenant_suspended | บัญชีชั้นใดชั้นหนึ่งในสายถูกระงับ | ติดต่อผู้ให้บริการของคุณ |
เช็กลิสต์หน้าคิวอนุมัติ
- [ ] แสดง
total_locked_minor(ยอด + ค่าธรรมเนียม) ไม่ใช่แค่ยอดถอน - [ ] แยกคิว "รอคนแรก" กับ "รอคนที่สอง"
- [ ] นับถอยหลัง 24 ชั่วโมงจาก
created_atของใบ พร้อม alert ที่ 4 ชั่วโมง - [ ] บังคับ
noteอย่างน้อย 20 ตัวอักษรที่ฝั่ง UI ด้วย ไม่ใช่รอ422 - [ ] ปุ่มอนุมัติเทาสำหรับคนที่สร้างใบและคนที่กดไปแล้ว
- [ ] อ่านผลจาก
data.object.statusไม่ใช่รอ event ชื่อwithdrawal.approved - [ ] เก็บ
Idempotency-Key+ body snapshot แยกตาม OIDC principal ก่อน ยิง; retry คนเดิมใช้ค่าเดิม ส่วนคนที่สองเป็น operation ใหม่แม้ literal key เหมือนกัน
ขั้นต่อไป
- หนึ่งใบรับได้หลายก้อน (partial fill) — สิ่งที่เกิดหลังใบเป็น
open - กระเป๋าเงินและยอดคงเหลือ — ทำไมยอดหายตั้งแต่ตอนสร้างใบ
- ตั้งค่า — เพดานที่คุณปรับได้และที่ชั้นบนกำหนด
- บันทึกและ audit log — ใครกดอนุมัติใบไหนเมื่อไร
- บัญชีผู้รับเงิน (beneficiary) — ต้องผ่านการยืนยันก่อนถึงสร้างใบได้
