Skip to content

Cancel a deposit ​

POST /v1/pay/deposits/{id}/cancel closes an order the user has not paid yet.

The one rule that decides everything

A slip has arrived = the order cannot be cancelled. Whatever the status is, even if the slip landed one second ago.

Once a slip exists, the user's money has already left their account. Cancelling would leave that money with no order behind it.


When cancelling is possible ​

statusCancellableCondition
awaiting_liquidity✅Always — there was never even a destination to pay
awaiting_payment✅Only while no slip exists · some orders are temporarily locked by destination-side conditions and return 409
slip_processing❌Paid, verification running
under_review❌Paid, waiting on a human
settled❌Already credited
expired · canceled · rejected · reversed❌Already closed

Request ​

bash
curl -X POST https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/cancel \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Timestamp: 1755750330" \
  -H "PG-Nonce: 01JQA5TF2S4U6W8Y0A2C4E6G8J" \
  -H "PG-Version: 2026-08-01" \
  -H "Idempotency-Key: cancel-ORD-9981-01" \
  -H "PG-Signature: v1=3a70...b2c5" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "user_changed_sender" }'
fieldtyperequirednotes
reasonstring✅Your own reason, stored for support to read · keep it legible, e.g. user_changed_sender · user_changed_amount · abandoned_checkout

Response 200 ​

json
{
  "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
  "object": "deposit_order",
  "version": 3,
  "status": "canceled",
  "is_terminal": false,
  "merchant_reference": "ORD-9981",
  "amount_minor": 500000,
  "currency": "THB",
  "canceled_by": "merchant",
  "cancel_reason_code": "merchant_request",
  "canceled_at": "2026-08-21T04:25:30.118Z",
  "late_slip_accepted_until": "2026-08-22T04:30:47.902Z",
  "created_at": "2026-08-21T04:10:47.902Z",
  "metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}
fieldPossible valuesMeaning
canceled_bymerchant · end_user · systemWho closed it
cancel_reason_codemerchant_request · end_user_request · no_liquidity_available · approval_ttl_expired🔴 open enum — your switch needs a default:
late_slip_accepted_untiltimestamp or nullUntil when late slips are still accepted · null = fully closed
is_terminalfalse or true🔴 the only field that tells you whether the order may be closed

canceled is not always the end ​

The case that costs users real money

A user taps cancel on your web page at the exact moment their other hand confirms the transfer in their banking app. In a system with real volume this happens several times a week.

If you close the order the second you see the word canceled, that user has nowhere to upload their slip, and money that really moved becomes a manual refund case.

⇒ An order that is cancelled after it had a destination carries late_slip_accepted_until and is_terminal: false. Keep the upload button until that timestamp.

js
// ❌ wrong
if (d.status === 'canceled') closeOrderAndHideUploadButton()

// ✅ correct
if (d.is_terminal) {
  closeOrder()
} else if (d.late_slip_accepted_until) {
  showUploadButton({ until: d.late_slip_accepted_until })
}

A slip after cancellation always goes to manual review ​

Uploading a slip to a canceled order inside the window returns the usual 202, but the outcome is forced to under_review (CANCELED_ORDER_SLIP) every time — it can never clear automatically, because someone has to confirm that the money really belongs to this order.

It is also never auto-rejected.

When the window actually closes ​

At late_slip_accepted_until we fire deposit.canceled again, with is_terminal: true and a new version. That is the moment you can close the order and hide the upload button.

After that, uploads return 410 late_slip_window_closed with details.claim_url.


System cancellation — no liquidity ​

An order that sits in awaiting_liquidity until the wait runs out without ever finding a destination is closed as canceled, not expired.

json
{
  "id": "dep_01K3M8B2VQ7HYD4NKR0XF9TW6E",
  "object": "deposit_order",
  "version": 3,
  "status": "canceled",
  "is_terminal": true,
  "merchant_reference": "ORD-9983",
  "amount_minor": 500000,
  "currency": "THB",
  "canceled_by": "system",
  "cancel_reason_code": "no_liquidity_available",
  "canceled_at": "2026-08-21T04:12:15.660Z",
  "late_slip_accepted_until": null,
  "created_at": "2026-08-21T04:10:47.902Z",
  "metadata": { "order_id": "ORD-9983" }
}

Why not expired

expired opens a 24-hour late-slip window and the UI shows an "I already transferred" button. But this order never had a destination — the user never saw a QR ⇒ that button could only produce an empty support ticket.

Note that on this path late_slip_accepted_until is null and is_terminal is true from the start.

The message to show the user: ขณะนี้ช่องทางโอนไม่ว่าง กรุณาลองใหม่อีกครั้ง ("no transfer route is available right now, please try again") with a button to create a new order. 🔴 Do not just say "your order was cancelled" — the user will assume they did something wrong.


Errors ​

HTTPcodeWhenWhat to do
409deposit_not_cancelableA slip already arrived, or the order is temporarily locked by a destination-side conditionLet it run. Do not retry in a loop · read the latest state with GET and update the screen
404resource_not_foundThe id does not exist or is not yoursCheck the id
409idempotency_in_flightThe user hammered the cancel buttonWait 1 second and retry with the same key
js
// Node — cancel that handles the 409 properly
async function cancelDeposit(depositId, reason) {
  try {
    return await post(`/v1/pay/deposits/${depositId}/cancel`, { reason })
  } catch (err) {
    if (err.code === 'deposit_not_cancelable') {
      // Too late — the user already transferred. Show the real state instead of an error.
      const d = await getDeposit(depositId)
      return { canceled: false, deposit: d }
    }
    throw err
  }
}

The screen to show on 409 deposit_not_cancelable:

ยกเลิกไม่ได้แล้ว เพราะเราได้รับสลิปของคุณแล้ว
กำลังตรวจสอบ เงินของคุณไม่หาย

English gloss: "This can no longer be cancelled because we have received your slip. It is being verified. Your money is safe."


Cases where cancel is step one ​

An existing order cannot be edited at all. Every change of mind is cancel the old order, create a new one.

What the user wantsSteps
Change the sending accountcancel → POST /v1/pay/deposits with the new sender + a new Idempotency-Key
Change the amount (e.g. picking a suggested amount while waiting for liquidity)cancel → create with the new amount + a new merchant_reference + a new key
Close an open order so a new one can be createdcancel the open one first, otherwise you hit the cap and get 409 too_many_open_deposits

Body changed = Idempotency-Key must change

Reusing the old key with a new body returns 409 idempotency_key_reused. Generating a fresh key on every retry is the opposite mistake — that gives you duplicate orders and two QRs.

The rule: one intent = one key. Write it to your DB before the call, and have retries read the same value back.

Cancelling and recreating immediately can queue

Some routes have a short cool-off (~3 minutes) after a cancellation before the same amount from the same sending account can be matched again. If you build a "change sending account" or "change amount" button, track the cancellation time on your side and tell the user plainly that the new order may take longer to find a route, instead of leaving them confused on a waiting screen.


Do you have to cancel when the user just closes the browser? ​

No — the order expires by itself at expires_at and becomes expired.

Call cancel only when the user has decided not to transfer: they pressed cancel, or chose to change the account or amount. Cancelling orders that users simply abandoned achieves nothing and destroys your ability to tell deliberate cancellations apart from drop-offs.


Next ​

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