Skip to content

ยกเลิกรายการถอน ​

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 ​

json
{ "reason": "user_canceled_from_app" }

reason เป็นข้อความอิสระของคุณ ใช้ไว้สืบย้อนตอนมีปัญหา

curl ​

bash
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 ของรายการถอนใบนั้นในสถานะล่าสุด

json
{
  "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.canceledcanceled_by · refunded_minor · data.object เต็มใบ
balance.creditedเงินกลับเข้ากระเป๋า — เป็น สองรายการ คือเงินต้นที่ยังไม่ถูกเติม และค่าธรรมเนียมส่วนที่ยังไม่ถูกใช้
json
{
  "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 ​

HTTPcodeเกิดเมื่อretry ได้ต้องทำอะไร
409withdrawal_has_pending_fillมีก้อนที่จองไว้แล้วและผู้โอนกำลังจะโอน✅รอตาม details.retry_after_seconds แล้วลองใหม่
409withdrawal_not_cancelableใบนี้ settled / expired / canceled / rejected ไปแล้ว❌อ่านสถานะล่าสุดจาก GET แล้วอัปเดตหน้าจอ
409withdrawal_state_conflictสถานะขยับระหว่างทางพอดี✅อ่านสถานะล่าสุดจาก GET แล้วตัดสินใจใหม่
404resource_not_foundid ผิด หรือไม่อยู่ในขอบเขตของ key นี้❌ตรวจ id
400idempotency_key_requiredไม่ส่ง Idempotency-Key❌เพิ่ม header
401 / 403ชุด authลายเซ็น / key / สิทธิ์❌ดู การยืนยันตัวตนของ request
429rate_limitedชน bucket✅เคารพ Retry-After + jitter

409 withdrawal_has_pending_fill — ไม่ใช่บั๊ก ​

json
{
  "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 ที่ถูกต้อง ​

js
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
<?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 ⇒ ไม่มีใครกำลังโอน ⇒ ยกเลิกได้เสมอ และคืนเต็มจำนวน

json
{
  "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 ซ้ำ ๆ การปล่อยให้หมดอายุเองเป็นทางออกที่ปลอดภัยกว่าการยิงซ้ำถี่ ๆ

สิ่งที่ต้องทำในระบบคุณหลังยกเลิกสำเร็จ ​

  1. ปลดล็อกยอดผู้ใช้เฉพาะส่วนที่คืนจริง — ไม่ใช่ปลดล็อกทั้งก้อนที่เคยล็อกไว้ จากตัวอย่าง: ล็อกไว้ 102675 · ผู้ใช้ได้รับจริง 70000 · ค่าธรรมเนียมที่ถูกใช้ 1872 ⇒ ปลดล็อกคืน 30803 ตัดจริง 71872
  2. ปิดรายการแบบกันซ้ำ — ผูกกับคอลัมน์ ไม่ใช่ผูกกับ "เคยเห็น event นี้ไหม"
    sql
    UPDATE pg_withdrawals SET closed_at = now()
     WHERE pg_withdrawal_id = $1 AND status = 'canceled' AND closed_at IS NULL;
  3. อย่าลบบรรทัด fill ที่เคยได้รับ — ก้อนที่เติมสำเร็จไปแล้วยังต้องอยู่ในตาราง pg_withdrawal_fills เพื่อกระทบยอดกับ statement ธนาคาร
  4. กระทบยอดจาก balance.credited ไม่ใช่จาก field เดียวใน event นี้ — เงินต้นกับค่าธรรมเนียมคืนกันคนละรายการ

ขั้นต่อไป ​

ถ้าคุณกำลังจะไปที่
เข้าใจว่าทำไมมีก้อนค้างจนยกเลิกไม่ได้รับ fill หลายก้อน
สร้างใบใหม่หลังยกเลิกสร้างรายการถอน
คำนวณยอดที่ต้องล็อกให้ถูกตั้งแต่แรกคำนวณค่าธรรมเนียมล่วงหน้า
กัน event canceled เก่ามาทับสถานะใหม่ลำดับไม่การันตี — version guard
ดู state ทั้งหมดของรายการถอนState machine ทั้งหมด

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