Skip to content

Cancel a withdrawal ​

POST /v1/pay/withdrawals/{id}/cancel

You can only cancel the part that has not been filled

A chunk that has already been filled is a real transfer between two bank accounts. The money is in the end user's account. No button pulls it back.

So canceling means exactly one thing: close out remaining_minor and return the unfilled principal, plus the unused fee, to your wallet.

Withdraw 1,000, receive 700, then cancel ⇒ the user really has the 700, and what comes back to your wallet is the part covering the remaining 300.

When you can cancel, and what comes back ​

status when you callCancellableWhat is returned
pending_approval✅the full amount, amount_total_minor + fee_total_minor — it never entered the pool, nobody transferred
open✅ if no chunk is awaiting a transferthe full amount (filled_minor is still 0)
partially_filled✅ if no chunk is awaiting a transferremaining_minor plus the unused part of the fee
settled❌409 withdrawal_not_cancelable
expired / canceled / rejected❌409 withdrawal_not_cancelable

Request ​

json
{ "reason": "user_canceled_from_app" }

reason is your own free-text string. It is there for you to trace things later.

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 ​

Returns the withdrawal object in its latest state.

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" }
}

Webhooks follow.

eventcarries
withdrawal.canceledcanceled_by · refunded_minor · the full data.object
balance.creditedmoney back in your wallet — as two movements: the unfilled principal, and the unused part of the fee
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 and cancel_reason_code ​

canceled_bycause
merchantyou called this endpoint
end_userthe end user canceled from a screen we host
systemthe system canceled it, e.g. stuck at pending_approval for 24 hours

cancel_reason_code is an open enum. The values you can see today: merchant_request, end_user_request, approval_ttl_expired, no_liquidity_available. Your switch needs a default:.

Errors ​

HTTPcodewhenretryablewhat to do
409withdrawal_has_pending_filla chunk is reserved and its payer is about to transfer✅wait for details.retry_after_seconds, then try again
409withdrawal_not_cancelablethe order is already settled / expired / canceled / rejected❌read the latest state with GET and update the screen
409withdrawal_state_conflictthe state moved underneath you✅read the latest state with GET and decide again
404resource_not_foundwrong id, or outside this key's scope❌check the id
400idempotency_key_requiredno Idempotency-Key sent❌add the header
401 / 403the auth familysignature / key / scope❌see request authentication
429rate_limitedbucket exhausted✅respect Retry-After + jitter

409 withdrawal_has_pending_fill — not a bug ​

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 }
  }
}

Why you cannot cancel instantly — and why that is the right behavior

At that moment a real payer has their banking app open and is transferring into your user's account.

If we allowed an instant cancel, they would finish the transfer and find the order gone — their money has left their account with nothing on the other side to account for it. That is confiscating a third party's money.

So the system blocks the cancel until that chunk resolves (transfer completes, or the hold expires and it is released), and only then allows it.

What you have to build: the cancel button needs a "canceling" state, not just success and failure. Retry on retry_after_seconds with a cap — never in a tight loop.

Node — retrying correctly ​

js
import { pgFetch } from './pg-client.js'

export async function cancelWithdrawal(withdrawalId, ref, { maxAttempts = 6 } = {}) {
  const idemKey = `wdr-cancel-${ref}`     // 🔴 same key every attempt · only PG-Nonce changes

  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))   // ±10% jitter
      continue
    }
    if (code === 'withdrawal_not_cancelable') {
      // The order ran to completion while we were trying to cancel — not a user-facing error.
      const cur = await pgFetch('GET', `/v1/pay/withdrawals/${withdrawalId}`)
      return cur.body
    }
    throw new PgError(res.body.error)
  }
  // Could not cancel within the attempt budget → let the order run; it expires at expires_at.
  return null
}

PHP ​

php
<?php
function cancel_withdrawal(string $withdrawalId, string $ref, int $maxAttempts = 6): ?array
{
    $idemKey = 'wdr-cancel-' . $ref;   // same key every 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;   // let it expire on its own at expires_at
}

Cancelling at pending_approval — the easy case ​

An order still awaiting approval has never entered the pool, so nobody is transferring, so it can always be canceled — and everything comes back.

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, so the whole 9095 fee comes back too — 509095 returned to your wallet, exactly what was locked.

You do not have to cancel, if you can wait

Every order already has an expires_at. Past that point the system closes it for you with withdrawal.expired and returns the remainder in exactly the same way as a cancel.

If your cancel button keeps hitting 409 withdrawal_has_pending_fill, letting the order expire is safer than hammering the endpoint.

What to do on your side after a successful cancel ​

  1. Release only what actually came back — not the whole amount you originally locked. From the example: locked 102675 · the user really received 70000 · fee used 1872 ⇒ release 30803 and keep 71872 debited.
  2. Close the order idempotently — bind it to a column, not to "have I seen this event before".
    sql
    UPDATE pg_withdrawals SET closed_at = now()
     WHERE pg_withdrawal_id = $1 AND status = 'canceled' AND closed_at IS NULL;
  3. Do not delete the fill rows you already received — successfully filled chunks must stay in pg_withdrawal_fills so you can reconcile against the bank statement.
  4. Reconcile from balance.credited, not from a single field on this event — principal and fee come back as separate movements.

Next ​

If you are about toGo to
Understand why a stuck chunk blocks the cancelHandling partial fills
Create a new order after cancelingCreate a withdrawal
Get the locked amount right from the startQuote the fee first
Stop a stale canceled event from overwriting fresh stateOrdering and the version guard
See every withdrawal stateFull state machine

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