Cancel a withdrawal
POST /v1/pay/withdrawals/{id}/cancelYou 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 call | Cancellable | What 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 transfer | the full amount (filled_minor is still 0) |
partially_filled | ✅ if no chunk is awaiting a transfer | remaining_minor plus the unused part of the fee |
settled | ❌ | 409 withdrawal_not_cancelable |
expired / canceled / rejected | ❌ | 409 withdrawal_not_cancelable |
Request
{ "reason": "user_canceled_from_app" }reason is your own free-text string. It is there for you to trace things later.
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
Returns the withdrawal object in its latest state.
{
"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.
| event | carries |
|---|---|
withdrawal.canceled | canceled_by · refunded_minor · the full data.object |
balance.credited | money back in your wallet — as two movements: the unfilled principal, and the unused part of the fee |
{
"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_by | cause |
|---|---|
merchant | you called this endpoint |
end_user | the end user canceled from a screen we host |
system | the 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
| HTTP | code | when | retryable | what to do |
|---|---|---|---|---|
| 409 | withdrawal_has_pending_fill | a chunk is reserved and its payer is about to transfer | ✅ | wait for details.retry_after_seconds, then try again |
| 409 | withdrawal_not_cancelable | the order is already settled / expired / canceled / rejected | ❌ | read the latest state with GET and update the screen |
| 409 | withdrawal_state_conflict | the state moved underneath you | ✅ | read the latest state with GET and decide again |
| 404 | resource_not_found | wrong id, or outside this key's scope | ❌ | check the id |
| 400 | idempotency_key_required | no Idempotency-Key sent | ❌ | add the header |
| 401 / 403 | the auth family | signature / key / scope | ❌ | see request authentication |
| 429 | rate_limited | bucket exhausted | ✅ | respect Retry-After + jitter |
409 withdrawal_has_pending_fill — not a bug
{
"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
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
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.
{
"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
- Release only what actually came back — not the whole amount you originally locked. From the example: locked
102675· the user really received70000· fee used1872⇒ release30803and keep71872debited. - 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; - Do not delete the fill rows you already received — successfully filled chunks must stay in
pg_withdrawal_fillsso you can reconcile against the bank statement. - 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 to | Go to |
|---|---|
| Understand why a stuck chunk blocks the cancel | Handling partial fills |
| Create a new order after canceling | Create a withdrawal |
| Get the locked amount right from the start | Quote the fee first |
Stop a stale canceled event from overwriting fresh state | Ordering and the version guard |
| See every withdrawal state | Full state machine |
