Skip to content

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 claim

expired 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 ​

bash
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.

json
{ "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 ​

bash
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.

json
{
  "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"
}
FieldRequiredNotes
deposit_idnoinclude it if you know which order the money was meant for; leave it out if you do not
end_user_refyesthe user in your system who owns the money
amount_minoryesinteger satang, as shown on the slip
occurred_atyesthe timestamp on the slip · RFC3339 UTC ending in Z
sending_bank_codeyes3 digits, from GET /v1/pay/banks
trans_refnothe bank's reference number · having it resolves the case far faster
slip_filenothe slip image
descriptionyestell 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 ​

statusMeaningEffect on money
openreceived; an overpayment remains action_required_by:end_user until the user contacts supportmoney is captured into the claim path but is not refunded or credited
under_reviewsupport accepted the casenothing moves out of the claim account yet
refund_pendingpayout intent created; payment confirmation pendingliability remains intact; the resolution journal waits for bank confirmation
return_pending_payoutthe original transfer was returned while a submitted payout still has an unknown resultnever issue another refund; poll/cancel the same operation key to an authoritative result
creditedan unmatched_transfer was found and linked to the usera new deposit is created and settled ⇒ you get a normal deposit.settled · unavailable for overpayment
refundedthe claim-path refund completedthe excess liability was closed by a resolution journal
returned_by_bankthe original transfer reached the sender again (outcome:'transfer_returned')terminal, but not a platform payout; never refund again
rejectedan unmatched_transfer was not found / was unrelatedno 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

http
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 status

The 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_url over 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.overpaid while data.object.status stays settled.
json
{
  "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 500000 only
  • overpayment{cause:'same_transfer_excess',amount_minor:30000,action_required:'contact_support'} is attached
  • A claim and capture for 30000 commit 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:

bash
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 ​

bash
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.")

json
{
  "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.

  1. You need the balance. available_minor ≥ amount + fee, otherwise 402 insufficient_balance.
  2. Money received today is still in the pending-clearing bucket ⇒ a same-day refund usually fails until pending_clearing_available_at.
  3. There is a withdrawal fee, added on top ⇒ refunding THB 5,000.00 to the customer drops your wallet by THB 5,030.00.
  4. You need a beneficiary_id that is already verified, otherwise 409 beneficiary_not_verified and no order is created.
  5. It walks the withdrawal state machine in full ⇒ it can be filled by several depositors ⇒ withdrawal.filled arrives more than once.
  6. The events you receive are all withdrawal.*, never refund.* — tell them apart from ordinary withdrawals by the refund_of_deposit_id field.
json
{ "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.

json
{
  "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 ​

bash
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.

OutcomeEvent that followsMeaning
Staff decide the money did arrivewithdrawal.dispute_resolved (outcome: "confirmed")that chunk continues normally, and its 72-hour clock resumes
Staff decide the money did not arrivewithdrawal.dispute_resolved (outcome: "reversed") followed by withdrawal.fill_reversedthat 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.cleared that 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 is available_minor from GET /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_closed is handled by opening a claim, not by showing an error and stopping
  • [ ] claim_id is stored against the user, and for unmatched_transfer the old order is linked to the new one created by a credited outcome
  • [ ] claim.updated is subscribed
  • [ ] deposit.overpaid is handled without crediting anything extra
  • [ ] An overpayment decision uses only refund + reason_code + latest expected_version + verified sender beneficiary_id; it requires payout.approve, and credit/reject return 422
  • [ ] 202/refund_pending is distinct from refunded/refund_issued; report success only after the bank-confirmed claim.updated, and handle terminal failure returning to under_review/support with no journal
  • [ ] Handlers exist for all five events that cannot be unsubscribed
  • [ ] An alert fires when an unanswered withdrawal.disputed has less than half its window left
  • [ ] Your support team has a playbook: under_review means "being checked", not "error" · never advise a second transfer

Next ​

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