ยกเลิกรายการถอน
POST /v1/pay/withdrawals/{id}/cancelยกเลิกได้เฉพาะ "ส่วนที่ยังไม่ถูกเติม"
ก้อนที่เติมสำเร็จไปแล้วคือ การโอนเงินจริงระหว่างบัญชีธนาคารสองบัญชี เงินอยู่ในบัญชีผู้ใช้ปลายทางแล้ว ไม่มีปุ่มไหนดึงกลับได้
การกดยกเลิกจึงมีความหมายเดียว: ปิดส่วน remaining_minor แล้วคืนเงินต้นกับค่าธรรมเนียมที่ยังไม่ถูกใช้เข้ากระเป๋าคุณ
ถอน 1,000 ได้ไปแล้ว 700 แล้วกดยกเลิก ⇒ ผู้ใช้ได้ 700 ไปแล้วจริง คืนเข้ากระเป๋าคุณคือส่วนของ 300 ที่เหลือ
ยกเลิกได้ตอนไหน คืนเท่าไร
status ตอนกด | ยกเลิกได้ | คืนอะไร |
|---|---|---|
pending_approval | ✅ | คืนเต็มจำนวน amount_total_minor + fee_total_minor — ยังไม่เคยเข้า pool ไม่มีใครโอนให้ |
open | ✅ ถ้าไม่มีก้อนที่กำลังรอโอน | คืนเต็มจำนวน (filled_minor ยังเป็น 0) |
partially_filled | ✅ ถ้าไม่มีก้อนที่กำลังรอโอน | คืนเฉพาะ remaining_minor + ค่าธรรมเนียมส่วนที่ยังไม่ถูกใช้ |
settled | ❌ | 409 withdrawal_not_cancelable |
expired / canceled / rejected | ❌ | 409 withdrawal_not_cancelable |
Request
{ "reason": "user_canceled_from_app" }reason เป็นข้อความอิสระของคุณ ใช้ไว้สืบย้อนตอนมีปัญหา
curl
curl -sS -X POST 'https://api.example-wl.com/v1/pay/withdrawals/wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC/cancel' \
-H 'Authorization: Bearer sk_live_9f2c1ab6e0d74f' \
-H 'PG-Timestamp: 1755748821' \
-H 'PG-Nonce: 01K3PC8W1Y3A5C7E9G1J3L5N7Q' \
-H 'PG-Version: 2026-08-01' \
-H 'Idempotency-Key: wdr-cancel-WD-20260821-0007' \
-H 'PG-Signature: v1=1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e' \
-H 'Content-Type: application/json' \
-d '{ "reason": "user_canceled_from_app" }'Response 200
คืน object ของรายการถอนใบนั้นในสถานะล่าสุด
{
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 9,
"status": "canceled",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 70000,
"remaining_minor": 30000,
"refunded_minor": 30000,
"fee_recognized_minor": 1872,
"settled_splits": 2,
"max_splits": 4,
"canceled_by": "merchant",
"cancel_reason_code": "merchant_request",
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
}พร้อมกันนั้นจะมี webhook ตามมา
| event | มีอะไร |
|---|---|
withdrawal.canceled | canceled_by · refunded_minor · data.object เต็มใบ |
balance.credited | เงินกลับเข้ากระเป๋า — เป็น สองรายการ คือเงินต้นที่ยังไม่ถูกเติม และค่าธรรมเนียมส่วนที่ยังไม่ถูกใช้ |
{
"id": "evt_01K3PC9M4R6T8V0X2Z4B6D8F0H",
"object": "event",
"type": "withdrawal.canceled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:00:21.744Z",
"sequence": 91302,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"request": { "id": "req_01K3PC9M2Q", "idempotency_key": "wdr-cancel-WD-20260821-0007" },
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 9,
"status": "canceled",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 70000,
"remaining_minor": 30000,
"refunded_minor": 30000,
"fee_recognized_minor": 1872,
"settled_splits": 2,
"max_splits": 4,
"canceled_by": "merchant",
"cancel_reason_code": "merchant_request",
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
},
"previous_attributes": { "version": 8, "status": "partially_filled", "refunded_minor": 0 }
}
}canceled_by และ cancel_reason_code
canceled_by | เกิดจาก |
|---|---|
merchant | คุณเรียก endpoint นี้ |
end_user | ผู้ใช้ปลายทางกดยกเลิกในหน้าจอที่เราให้บริการ |
system | ระบบยกเลิกให้เอง เช่นค้างที่ pending_approval ครบ 24 ชั่วโมง |
cancel_reason_code เป็น open enum ค่าที่เจอได้ตอนนี้: merchant_request · end_user_request · approval_ttl_expired · no_liquidity_available ⇒ switch ต้องมี default:
Error
| HTTP | code | เกิดเมื่อ | retry ได้ | ต้องทำอะไร |
|---|---|---|---|---|
| 409 | withdrawal_has_pending_fill | มีก้อนที่จองไว้แล้วและผู้โอนกำลังจะโอน | ✅ | รอตาม details.retry_after_seconds แล้วลองใหม่ |
| 409 | withdrawal_not_cancelable | ใบนี้ settled / expired / canceled / rejected ไปแล้ว | ❌ | อ่านสถานะล่าสุดจาก GET แล้วอัปเดตหน้าจอ |
| 409 | withdrawal_state_conflict | สถานะขยับระหว่างทางพอดี | ✅ | อ่านสถานะล่าสุดจาก GET แล้วตัดสินใจใหม่ |
| 404 | resource_not_found | id ผิด หรือไม่อยู่ในขอบเขตของ key นี้ | ❌ | ตรวจ id |
| 400 | idempotency_key_required | ไม่ส่ง Idempotency-Key | ❌ | เพิ่ม header |
| 401 / 403 | ชุด auth | ลายเซ็น / key / สิทธิ์ | ❌ | ดู การยืนยันตัวตนของ request |
| 429 | rate_limited | ชน bucket | ✅ | เคารพ Retry-After + jitter |
409 withdrawal_has_pending_fill — ไม่ใช่บั๊ก
{
"error": {
"code": "withdrawal_has_pending_fill",
"type": "invalid_request_error",
"message": "A fill is currently reserved on this withdrawal.",
"message_th": "มีผู้โอนกำลังทำรายการกับยอดถอนนี้อยู่",
"retryable": true,
"retry_after_seconds": 45,
"request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6",
"details": { "retry_after_seconds": 45 }
}
}ทำไมยกเลิกทันทีไม่ได้ — และทำไมนี่คือสิ่งที่ถูกต้อง
ตอนนั้นมีผู้ฝากคนหนึ่งกำลังเปิดแอปธนาคารโอนเงินเข้าบัญชีผู้ใช้ของคุณอยู่จริง ๆ
ถ้าเรายอมให้ยกเลิกทันที เขาจะโอนเสร็จแล้วพบว่ารายการหายไป — เงินของเขาออกจากบัญชีไปแล้วแต่ไม่มีรายการรองรับ นั่นคือการยึดเงินของบุคคลที่สาม
ระบบจึงบล็อกไว้จนกว่าก้อนนั้นจะจบ (โอนสำเร็จ หรือหมดเวลาแล้วปล่อยคืน) แล้วค่อยยกเลิกได้
สิ่งที่คุณต้องทำเพิ่ม: ปุ่มยกเลิกต้องรองรับสถานะ "กำลังยกเลิก" ไม่ใช่สำเร็จ/ล้มเหลวอย่างเดียว · retry ตาม retry_after_seconds แบบมีเพดาน อย่ายิงถี่เป็นลูป
Node — retry ที่ถูกต้อง
import { pgFetch } from './pg-client.js'
export async function cancelWithdrawal(withdrawalId, ref, { maxAttempts = 6 } = {}) {
const idemKey = `wdr-cancel-${ref}` // 🔴 key เดิมทุก attempt · เปลี่ยนแค่ PG-Nonce
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await pgFetch('POST', `/v1/pay/withdrawals/${withdrawalId}/cancel`, {
idempotencyKey: idemKey,
body: { reason: 'user_canceled_from_app' },
})
if (res.status === 200) return res.body // canceled แล้ว
const code = res.body?.error?.code
if (code === 'withdrawal_has_pending_fill' || code === 'idempotency_in_flight') {
const wait = (res.body.error.retry_after_seconds ?? 30) * 1000
await sleep(wait * (0.9 + Math.random() * 0.2)) // jitter ±10%
continue
}
if (code === 'withdrawal_not_cancelable') {
// ใบนี้เดินไปจนจบแล้วระหว่างที่เรากำลังจะยกเลิก — ไม่ใช่ error ของผู้ใช้
const cur = await pgFetch('GET', `/v1/pay/withdrawals/${withdrawalId}`)
return cur.body
}
throw new PgError(res.body.error)
}
// ยกเลิกไม่สำเร็จในรอบที่กำหนด → ปล่อยให้ใบเดินต่อ เดี๋ยวมันหมดอายุเองที่ expires_at
return null
}PHP
<?php
function cancel_withdrawal(string $withdrawalId, string $ref, int $maxAttempts = 6): ?array
{
$idemKey = 'wdr-cancel-' . $ref; // key เดิมทุก attempt
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
[$status, $body] = pg_request(
'POST',
"/v1/pay/withdrawals/{$withdrawalId}/cancel",
['reason' => 'user_canceled_from_app'],
$idemKey
);
if ($status === 200) {
return $body;
}
$code = $body['error']['code'] ?? '';
if ($code === 'withdrawal_has_pending_fill' || $code === 'idempotency_in_flight') {
$wait = $body['error']['retry_after_seconds'] ?? 30;
sleep((int) max(1, $wait));
continue;
}
if ($code === 'withdrawal_not_cancelable') {
[, $cur] = pg_request('GET', "/v1/pay/withdrawals/{$withdrawalId}");
return $cur;
}
throw new PgError($body['error']);
}
return null; // ปล่อยให้หมดอายุเองที่ expires_at
}ยกเลิกตอน pending_approval — เคสที่ง่ายที่สุด
ใบที่ยังรออนุมัติยังไม่เคยเข้า pool ⇒ ไม่มีใครกำลังโอน ⇒ ยกเลิกได้เสมอ และคืนเต็มจำนวน
{
"id": "wdr_01K3PD2Q6S8U0W2Y4A6C8E0G2J",
"object": "withdrawal_order",
"version": 2,
"status": "canceled",
"merchant_reference": "WD-20260821-0011",
"currency": "THB",
"amount_total_minor": 500000,
"fee_total_minor": 9095,
"total_locked_minor": 509095,
"filled_minor": 0,
"remaining_minor": 500000,
"refunded_minor": 500000,
"fee_recognized_minor": 0,
"settled_splits": 0,
"canceled_by": "merchant",
"cancel_reason_code": "merchant_request"
}fee_recognized_minor = 0 ⇒ ค่าธรรมเนียมทั้ง 9095 ถูกคืนด้วย รวมกลับเข้ากระเป๋า 509095 เท่าที่ล็อกไปเป๊ะ
ไม่ยกเลิกก็ได้ ถ้ารอไหว
ทุกใบมี expires_at อยู่แล้ว พ้นเวลานั้นระบบจะปิดให้เองด้วย withdrawal.expired และคืนส่วนที่เหลือแบบเดียวกับการยกเลิกทุกประการ
ถ้าปุ่มยกเลิกของคุณติด 409 withdrawal_has_pending_fill ซ้ำ ๆ การปล่อยให้หมดอายุเองเป็นทางออกที่ปลอดภัยกว่าการยิงซ้ำถี่ ๆ
สิ่งที่ต้องทำในระบบคุณหลังยกเลิกสำเร็จ
- ปลดล็อกยอดผู้ใช้เฉพาะส่วนที่คืนจริง — ไม่ใช่ปลดล็อกทั้งก้อนที่เคยล็อกไว้ จากตัวอย่าง: ล็อกไว้
102675· ผู้ใช้ได้รับจริง70000· ค่าธรรมเนียมที่ถูกใช้1872⇒ ปลดล็อกคืน30803ตัดจริง71872 - ปิดรายการแบบกันซ้ำ — ผูกกับคอลัมน์ ไม่ใช่ผูกกับ "เคยเห็น event นี้ไหม"sql
UPDATE pg_withdrawals SET closed_at = now() WHERE pg_withdrawal_id = $1 AND status = 'canceled' AND closed_at IS NULL; - อย่าลบบรรทัด fill ที่เคยได้รับ — ก้อนที่เติมสำเร็จไปแล้วยังต้องอยู่ในตาราง
pg_withdrawal_fillsเพื่อกระทบยอดกับ statement ธนาคาร - กระทบยอดจาก
balance.creditedไม่ใช่จาก field เดียวใน event นี้ — เงินต้นกับค่าธรรมเนียมคืนกันคนละรายการ
ขั้นต่อไป
| ถ้าคุณกำลังจะ | ไปที่ |
|---|---|
| เข้าใจว่าทำไมมีก้อนค้างจนยกเลิกไม่ได้ | รับ fill หลายก้อน |
| สร้างใบใหม่หลังยกเลิก | สร้างรายการถอน |
| คำนวณยอดที่ต้องล็อกให้ถูกตั้งแต่แรก | คำนวณค่าธรรมเนียมล่วงหน้า |
กัน event canceled เก่ามาทับสถานะใหม่ | ลำดับไม่การันตี — version guard |
| ดู state ทั้งหมดของรายการถอน | State machine ทั้งหมด |
