Claims
This page answers one question: the customer's money has left their account, but there is no order in the system holding it — now what?
This is where we differ from the market the most
A gateway whose money lands in its own pooled account can always see that money, even if the customer does nothing at all. Our money goes straight into the bank account of another merchant who is withdrawing ⇒ with no slip to bind it, the system has no way of knowing whose money it is.
⇒ So we have a path nobody else has: a 24-hour late-slip window, followed by a claims system with real people holding each case. What that means for you: your support team needs a playbook for this from day one, and your web pages need an "I already transferred but it expired" button.
Timeline of the "transferred but never uploaded" case
T+0 order created status = awaiting_payment QR shown
T+20m expires_at status = expired is_terminal = false
late_slip_accepted_until = T+20m+24h
⬑ during this window the slip still uploads normally; POST /slip still answers 202
T+24h20m window closes deposit.expired fires again is_terminal = true (new version)
⬑ after this POST /slip answers 410 with a claim_url
⬑ the only remaining path is to open a claimexpired arrives twice, and you must handle both
The first time: is_terminal: false plus late_slip_accepted_until ⇒ do not close the order. The second time: is_terminal: true ⇒ now you may close it. Both are the same event type. They differ by version and is_terminal ⇒ without a version guard, the older one can overwrite the newer one.
A slip that arrives after the window
curl -sS -X POST "$SLIP_UPLOAD_URL" \
-H "Authorization: Bearer $SLIP_UPLOAD_TOKEN" \
-H "Idempotency-Key: slip-dep01K3M7Y9-late" \
-F "file=@slip.jpg"Use the order capability's upload_url directly—never construct its host/path—and precheck content_types/max_bytes before sending.
{ "error": {
"code": "late_slip_window_closed",
"type": "invalid_request_error",
"message": "The late slip window for this deposit has closed.",
"message_th": "หมดเวลารับสลิปย้อนหลังสำหรับรายการนี้แล้ว",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01JQA6XY7M9P1R3T5V7X9Z1B3D",
"details": {
"claim_url": "https://api.example-wl.com/v1/bo/claims",
"deposit_id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"late_slip_accepted_until": "2026-08-22T04:30:47.902Z"
} } }🔴 410 is not an error that goes away on retry. Move straight on to details.claim_url. Do not let the case end here — the customer's money really has left their account.
Opening a claim
curl -sS -X POST "https://api.example-wl.com/v1/bo/claims" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
-H "Idempotency-Key: claim-u88213-20260821-01" \
-F "deposit_id=dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" \
-F "end_user_ref=u_88213" \
-F "amount_minor=500000" \
-F "occurred_at=2026-08-21T04:12:33.881Z" \
-F "sending_bank_code=002" \
-F "trans_ref=015520260821041233881" \
-F "description=ลูกค้าโอน 5,000 บาท ตามรายการนี้ แต่ปิดเบราว์เซอร์ก่อนอัปสลิป แนบสลิปจากแอปธนาคารมาให้แล้ว" \
-F "slip_file=@slip.jpg"The /v1/bo/claims* routes use the operator's OIDC access token, not sk_live_* or PG HMAC. POST /v1/bo/claims needs claims:write, while GET /v1/bo/claims* needs claims:read; opening or reading does not move money, so those calls do not require step-up.
The exception is POST /v1/bo/claims/{id}/decisions: a refund action requires both claims:write and the payout.approve capability, plus a request-bound X-PG-Step-Up-Token and Idempotency-Key. Ordinary support can receive the claim and verify evidence; an operator authorized to move money approves the payout. Derive the actor from OIDC sub and write it to the audit trail; never accept an actor_id or employee id in the request body.
(The Thai description reads: "The customer transferred THB 5,000 for this order but closed the browser before uploading the slip; slip from the banking app attached." Write it in whatever language the people who work your claims read.)
sending_bank_code 002 is Bangkok Bank. Pull the authoritative list from GET /v1/pay/banks rather than hard-coding it.
{
"claim_id": "clm_01K3TB4N8R2VXZ5YT0KDMWQH7P",
"version": 1,
"kind": "unmatched_transfer",
"cause": "late_transfer",
"status": "open",
"action_required_by": "support",
"deposit_id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"end_user_ref": "u_88213",
"amount_minor": 500000,
"expected_response_at": "2026-08-22T04:12:00.000Z",
"created_at": "2026-08-21T04:12:33.881Z",
"updated_at": "2026-08-21T04:12:33.881Z"
}| Field | Required | Notes |
|---|---|---|
deposit_id | no | include it if you know which order the money was meant for; leave it out if you do not |
end_user_ref | yes | the user in your system who owns the money |
amount_minor | yes | integer satang, as shown on the slip |
occurred_at | yes | the timestamp on the slip · RFC3339 UTC ending in Z |
sending_bank_code | yes | 3 digits, from GET /v1/pay/banks |
trans_ref | no | the bank's reference number · having it resolves the case far faster |
slip_file | no | the slip image |
description | yes | tell the whole story; the team picking up the case reads this field first |
Always supply trans_ref if you can get it
The pair (sending_bank_code, trans_ref) is the most certain identifier of a bank transaction, and it is the same pair the system uses for duplicate-slip protection. Cases with a trans_ref are verified almost immediately. Cases with only a blurry photo need a human to read it.
Possible states and outcomes
status | Meaning | Effect on money |
|---|---|---|
open | received; an overpayment remains action_required_by:end_user until the user contacts support | money is captured into the claim path but is not refunded or credited |
under_review | support accepted the case | nothing moves out of the claim account yet |
refund_pending | payout intent created; payment confirmation pending | liability remains intact; the resolution journal waits for bank confirmation |
return_pending_payout | the original transfer was returned while a submitted payout still has an unknown result | never issue another refund; poll/cancel the same operation key to an authoritative result |
credited | an unmatched_transfer was found and linked to the user | a new deposit is created and settled ⇒ you get a normal deposit.settled · unavailable for overpayment |
refunded | the claim-path refund completed | the excess liability was closed by a resolution journal |
returned_by_bank | the original transfer reached the sender again (outcome:'transfer_returned') | terminal, but not a platform payout; never refund again |
rejected | an unmatched_transfer was not found / was unrelated | no money moves, and you get a reason · unavailable for overpayment because that money was verified |
For kind=overpayment, the only allowed decision is action:'refund'. credit|reject belong to kind=unmatched_transfer; sending either action for an overpayment returns 422 claim_action_not_allowed.
Two ways to follow the outcome
GET /v1/bo/claims?kind=overpayment&status=open newly contacted users
GET /v1/bo/claims?kind=overpayment&status=under_review support queue, including failed payouts
GET /v1/bo/claims?kind=overpayment&status=refund_pending payout-monitoring queue; not yet refunded
GET /v1/bo/claims?kind=overpayment&status=return_pending_payout monitor the transfer-return/payout race
GET /v1/bo/claims/{claim_id} fetch current statusThe endpoint accepts one status at a time, so fetch and fully paginate each queue separately.
or subscribe to claim.updated (recommended), applying it only when claim.version is newer than the stored version.
An unmatched-transfer claim.updated with the outcome credited is followed by a deposit.settled for a new order
Not the original one — it is a different deposit_id. Your user-crediting handler is already keyed on deposit_id + credited_user_at IS NULL ⇒ it will credit correctly on its own. But the user's "top-up history" screen will show both the old failed order and the new successful one ⇒ link the two together using the claim_id you stored yourself, or the customer will call to ask why there are two entries.
Other cases that need a claim
The QR was screenshotted and reused outside any order
The user saved the QR image and transferred again days later without creating a new order ⇒ money lands in the withdrawer's account with no order backing it.
- There is no automatic recovery, because nothing binds that money to your user.
- Open the claim without a
deposit_id. - 🔴 Prevent it in the UI: show the line "QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้ ห้ามบันทึกไว้" ("This QR is valid once, for this order only. Do not save it.") and prefer
checkout_urlover rendering the QR yourself.
A duplicate transfer for one order (connection dropped, no confirmation screen)
This happens every day. The system opens the claim/flag automatically, but never refunds it automatically.
- The second slip has a different
trans_ref⇒ it passes duplicate-slip protection, but the order has already settled. - The system does not touch the original order and opens a claim by itself.
- You receive
deposit.overpaidwhiledata.object.statusstayssettled.
{
"id": "evt_01K3PC9R4W7YZ2XT5NBVKD8H0M",
"object": "event",
"type": "deposit.overpaid",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:57:12.409Z",
"sequence": 88402,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 11,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"created_at": "2026-08-21T04:10:47.902Z",
"ordered_amount_minor": 500000,
"transferred_amount_minor": 500000,
"amount_received_minor": 500000,
"fee_total_minor": 7500,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 5000, "rate_bps": 100 },
{ "component": "fixed_fee", "amount_minor": 2500 }
],
"credited_amount_minor": 492500,
"partial": false,
"settled_via": "auto",
"liquidity_source": "internal",
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"settled_at": "2026-08-21T04:12:33.881Z",
"clearing_available_at": "2026-08-24T04:12:33.881Z",
"currency": "THB",
"overpayment": {
"claim_id": "clm_01K3PC9R3V6XY1WS4MAUJC7G9L",
"cause": "additional_transfer",
"amount_minor": 500000,
"slip_received_at": "2026-08-21T04:56:58.220Z",
"status": "open",
"action_required": "contact_support",
"expected_response_at": "2026-08-22T04:56:58.000Z"
}
},
"previous_attributes": { "version": 10, "overpayment": null }
}
}Never credit the user extra off deposit.overpaid
The excess is not credit for this order and is not part of your wallet. status is still settled, which means the original order was correct. Do not roll it back. The snapshot's transferred_amount_minor=500000 remains V for the original settlement evidence; it is not cumulative. The additional THB 5,000 transfer lives separately in overpayment.amount_minor. Do not apply V=A+E when cause:'additional_transfer'. Show: "The original order succeeded, but we found an additional transfer. Contact support with this claim number," plus overpayment.claim_id. Never say the refund is already complete.
Overpayment on a single slip
For ordered X=500000 and bank-verified V=530000, when the other five dimensions pass, the system succeeds the order immediately:
- One
deposit.settled:transferred_amount_minor=530000,amount_received_minor=500000,partial:false - The user credit and fee base are
500000only overpayment{cause:'same_transfer_excess',amount_minor:30000,action_required:'contact_support'}is attached- A claim and capture for
30000commit atomically; there is no automatic refund and no SUSPENSE posting
If another dimension such as sender, receiver, or time still fails, the order goes to review. A later support approval uses the same X-plus-flag-E result.
Show: The order succeeded at THB 5,000.00, but we found an excess THB 300.00 transfer. Contact support with claim CLM…
Do not use POST /v1/pay/refunds for an overpayment
The ordinary refund endpoint debits the merchant wallet again. Resolve the excess through its claim so the captured liability/source is used and the merchant is not charged twice.
Deciding an overpayment refund after the user contacts support
Fetch the latest claim first, then send the expected_version the operator saw and the payer's verified beneficiary:
curl -sS -X POST "https://api.example-wl.com/v1/bo/claims/clm_01K3PB6QW1M8HN0DZ2XKQ7RV4T/decisions" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
-H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
-H "Idempotency-Key: claim-clm01K3PB6-refund-v3" \
-H "Content-Type: application/json" \
-d '{
"action": "refund",
"reason_code": "CUSTOMER_REQUESTED_REFUND",
"expected_version": 3,
"beneficiary_id": "ben_01K2R7Q4M8N0PZ3XT5VKD9WY2C",
"note": "User contacted support and confirmed the verified sender account for the excess refund"
}'reason_code:'CUSTOMER_REQUESTED_REFUND' is required for an excess refund. A stale expected_version returns 409 stale_claim_version, preventing two operators from deciding the same version. beneficiary_id must be the verified sender beneficiary; an unverified account returns 409 beneficiary_not_verified and moves no money. Overpayment is verified money, so credit and reject are invalid (422 claim_action_not_allowed).
A 202 response means the payout intent was durably created and the Claim moved to refund_pending—it does not mean the user has received the money. Every retry uses the same provider operation key. Wait for the later claim.updated with status:'refunded' and outcome:'refund_issued', emitted only after authoritative bank confirmation. A timeout or unknown state stays refund_pending and polls the same operation; never create another payout.
If the bank confirms a terminal payout failure, the system writes no resolution journal and emits a newer claim.updated returning the Claim to status:'under_review', action_required_by:'support'. The operator must recheck the source and beneficiary, fetch the latest version, and only then submit a new decision with that expected_version and a new Idempotency-Key; never guess the payout state and retry it as a new transfer.
If the bank/provider returns the original transfer before a submitted payout is resolved, the Claim may move through return_pending_payout while the same operation is polled or canceled, then finish as returned_by_bank with outcome:'transfer_returned'. Neither state permits a new decision or payout. returned_by_bank means the original transfer was returned; it does not mean the platform sent a refund.
Refunding a user
curl -sS -X POST "https://api.example-wl.com/v1/pay/refunds" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755840300" \
-H "PG-Nonce: 01JQA6Y53T5V7X9Z1B3D5F7H9K" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: refund-dep01K3M7Y9-full" \
-H "PG-Signature: v1=5a3c1e0f8d6b4a2c0e8f6d4b2a0c8e6f4d2b0a8c6e4f2d0b8a6c4e2f0d8b6a4c" \
-H "Content-Type: application/json" \
-d '{
"deposit_id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"amount_minor": 500000,
"beneficiary_id": "ben_01K2R7Q4M8N0PZ3XT5VKD9WY2C",
"reason": "ลูกค้ายกเลิกคำสั่งซื้อและขอคืนเงินเต็มจำนวน"
}'(The Thai reason reads: "Customer cancelled the order and requested a full refund.")
{
"id": "wdr_01K3TC2P6S9VXZ4YT1KDMWQH8R",
"object": "withdrawal_order",
"version": 1,
"status": "open",
"kind": "refund",
"refund_of_deposit_id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"amount_total_minor": 500000,
"fee_total_minor": 3000,
"total_locked_minor": 503000,
"currency": "THB",
"expires_at": "2026-08-21T22:05:00.000Z",
"created_at": "2026-08-21T10:05:00.221Z"
}🔴 A refund is a kind of withdrawal, not a reversal of the deposit
Everything below follows from that, and every point trips up people who have integrated other gateways.
- You need the balance.
available_minor ≥ amount + fee, otherwise402 insufficient_balance. - Money received today is still in the pending-clearing bucket ⇒ a same-day refund usually fails until
pending_clearing_available_at. - There is a withdrawal fee, added on top ⇒ refunding THB 5,000.00 to the customer drops your wallet by THB 5,030.00.
- You need a
beneficiary_idthat is already verified, otherwise409 beneficiary_not_verifiedand no order is created. - It walks the withdrawal state machine in full ⇒ it can be filled by several depositors ⇒
withdrawal.filledarrives more than once. - The events you receive are all
withdrawal.*, neverrefund.*— tell them apart from ordinary withdrawals by therefund_of_deposit_idfield.
{ "error": {
"code": "insufficient_balance",
"type": "invalid_request_error",
"message": "Available balance is lower than amount plus fee.",
"message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01JQA6Y53T5V7X9Z1B3D5F7H9K",
"details": {
"available_minor": 12000,
"required_minor": 503000,
"pending_clearing_minor": 492500,
"pending_clearing_available_at": "2026-08-24T04:12:33.881Z"
} } }Give the customer a date, not the words "shortly"
Use pending_clearing_available_at directly — "refundable from 24 Aug, 11:12" beats "in progress" every single time.
Submitting evidence in a dispute
The end user on the other side of your withdrawal presses "I did not receive the money" within 72 hours of a chunk settling.
{
"id": "evt_01K3TD5Q8W1YZ4XT7NBVKD2H6M",
"object": "event",
"type": "withdrawal.disputed",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T12:31:44.907Z",
"sequence": 90551,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
"object": "withdrawal_order",
"version": 9,
"status": "partially_filled",
"amount_total_minor": 1000000,
"filled_minor": 400000,
"remaining_minor": 600000,
"currency": "THB",
"funds_on_hold": true,
"dispute": {
"id": "dsp_01K3TD5Q7V0XY3WS6MAUJC9G4L",
"reason_code": "FUNDS_NOT_RECEIVED",
"amount_minor": 400000,
"opened_at": "2026-08-21T12:31:44.712Z",
"respond_by": "2026-08-23T12:31:44.712Z",
"evidence_url": "https://api.example-wl.com/v1/pay/disputes/dsp_01K3TD5Q7V0XY3WS6MAUJC9G4L/evidence"
}
},
"previous_attributes": { "version": 8, "dispute": null, "funds_on_hold": false }
}
}status does not change when a dispute opens
withdrawal.disputed is an attribute change — the order is still partially_filled. It has simply gained dispute{} and funds_on_hold, and version has moved. Code that does switch (event.type) and sets a status from the event name will write the wrong status ⇒ always read data.object.status.
Responding before respond_by
curl -sS -X POST "https://api.example-wl.com/v1/pay/disputes/dsp_01K3TD5Q7V0XY3WS6MAUJC9G4L/evidence" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755870000" \
-H "PG-Nonce: 01JQA7ZB5V7X9Z1B3D5F7H9K1M" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: dispute-dsp01K3TD5Q-evidence-01" \
-H "PG-Signature: v1=0c8e6f4d2b0a8c6e4f2d0b8a6c4e2f0d8b6a4c2e0f8d6b4a2c0e8f6d4b2a0c8e" \
-H "Content-Type: application/json" \
-d '{"kind":"bank_statement_line","reference":"merchant-ledger-2026-08-21#line-009733","note":"Checked the ledger and found no incoming THB 4,000 transfer"}'This endpoint accepts reference-only JSON, never a file or multipart body. kind: bank_statement_line · chat_transcript · id_document_ref · other.
| Outcome | Event that follows | Meaning |
|---|---|---|
| Staff decide the money did arrive | withdrawal.dispute_resolved (outcome: "confirmed") | that chunk continues normally, and its 72-hour clock resumes |
| Staff decide the money did not arrive | withdrawal.dispute_resolved (outcome: "reversed") followed by withdrawal.fill_reversed | that chunk is reversed and the amount returns to the order |
withdrawal.fill_reversed cannot be unsubscribed
Same as deposit.reversed and balance.debited — it takes money out of a merchant wallet. The system requires your endpoint to subscribe to all five of these, with no opt-out: deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled. Without handlers you only ever see money coming in, and you find out at month end that your books do not balance.
Not responding before respond_by means accepting the outcome
respond_by is normally ~48 hours after the dispute opens. Set your alert at the halfway mark, not one hour before the deadline.
As the depositing side, you get no dispute events
When the withdrawer on the other side disputes a chunk matched to your deposit, the money you were just credited is held until the case closes.
- Your deposit stays
settled. There is no new event. - But the
balance.clearedthat should have arrived at the 72-hour mark will not arrive until the case is decided. - ⇒ 🔴 Do not drive logic off
available_at. The only number you can decide on isavailable_minorfromGET /v1/pay/balance.
If the case ends in a reversal, you get deposit.reversed + balance.debited, taken out of the pending_clearing bucket ⇒ your wallet does not go negative.
Claims checklist
- [ ] Your web page has an "I already transferred but it expired" button until
late_slip_accepted_until - [ ]
410 late_slip_window_closedis handled by opening a claim, not by showing an error and stopping - [ ]
claim_idis stored against the user, and forunmatched_transferthe old order is linked to the new one created by acreditedoutcome - [ ]
claim.updatedis subscribed - [ ]
deposit.overpaidis handled without crediting anything extra - [ ] An overpayment decision uses only
refund+reason_code+ latestexpected_version+ verified senderbeneficiary_id; it requirespayout.approve, andcredit/rejectreturn422 - [ ]
202/refund_pendingis distinct fromrefunded/refund_issued; report success only after the bank-confirmedclaim.updated, and handle terminal failure returning tounder_review/supportwith no journal - [ ] Handlers exist for all five events that cannot be unsubscribed
- [ ] An alert fires when an unanswered
withdrawal.disputedhas less than half its window left - [ ] Your support team has a playbook:
under_reviewmeans "being checked", not "error" · never advise a second transfer
Next
- Wallet and balances — why same-day refunds usually fail
- Transaction lookup — attaching evidence to an open case, and compatibility
force-completeonly for a short-pay that has another review reason - Uploading a slip — successful durable intake is 202 — the late-slip window
- All events —
claim.updated·deposit.overpaid·withdrawal.disputed - Logs and audit trail — checking whether every related event actually reached you
