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
status | Cancellable | Condition |
|---|---|---|
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
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" }'| field | type | required | notes |
|---|---|---|---|
reason | string | ✅ | Your own reason, stored for support to read · keep it legible, e.g. user_changed_sender · user_changed_amount · abandoned_checkout |
Response 200
{
"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" }
}| field | Possible values | Meaning |
|---|---|---|
canceled_by | merchant · end_user · system | Who closed it |
cancel_reason_code | merchant_request · end_user_request · no_liquidity_available · approval_ttl_expired | 🔴 open enum — your switch needs a default: |
late_slip_accepted_until | timestamp or null | Until when late slips are still accepted · null = fully closed |
is_terminal | false 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.
// ❌ 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.
{
"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
| HTTP | code | When | What to do |
|---|---|---|---|
409 | deposit_not_cancelable | A slip already arrived, or the order is temporarily locked by a destination-side condition | Let it run. Do not retry in a loop · read the latest state with GET and update the screen |
404 | resource_not_found | The id does not exist or is not yours | Check the id |
409 | idempotency_in_flight | The user hammered the cancel button | Wait 1 second and retry with the same key |
// 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 wants | Steps |
|---|---|
| Change the sending account | cancel → 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 created | cancel 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
- Open orders — which order should be cancelled before creating a new one
- User choices — every "change of mind" that starts with a cancel
- Upload a slip — late slips on cancelled orders
- Deposit status — how
is_terminaldiffers from the status name - The sender account — the "change sending account" button your QR screen should have
