Transaction lookup
This page describes the tool your team uses when a customer gets in touch. It is not the path your code uses to drive business logic.
Money decisions come from webhooks plus a version guard, nothing else
GET is the fallback for webhooks, not the primary source. If you must poll, back off 3 → 5 → 10 → 30 seconds and stop once the order is terminal. Polling every order once a second will hit 429 and back off the whole API key ⇒ your other orders stop being creatable too.
Reading one deposit
curl -sS "https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=8f2c1ab6e0d74f9b3c5a7e1d0f6b8a4c2e9d7b5a3c1f0e8d6b4a2c0e8f6d4b2a"{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 6,
"status": "under_review",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:30:47.902Z",
"liquidity_source": "internal",
"review": {
"opened_at": "2026-08-21T04:19:08.601Z",
"escalated": false,
"four_eyes_required": false,
"estimated_resolution_bucket": "under_1h",
"reasons": [
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "ชื่อบัญชีผู้โอนในสลิปไม่ตรงกับชื่อที่แจ้งไว้ตอนสร้างรายการ เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "The sender name on the slip does not match the name registered for this order.",
"actionable_by": "end_user",
"detail": { "registered_name_masked": "ป*** ส***", "matched": false }
}
]
},
"slip": { "received_at": "2026-08-21T04:19:02.180Z", "is_late": false },
"verification": {
"amount_exact": true,
"within_time_window": true,
"sender_bank_match": true,
"sender_account_match": true,
"sender_name_match": false,
"receiver_match": true
},
"sender": {
"bank_code": "002",
"account_number_masked": "xxx-x-x5567",
"account_name_th_masked": "ป*** ส***"
},
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
}display_th is the Thai copy meant for the payer's screen — your end users are in Thailand, so render it verbatim. In English it reads: "The sender account name on the slip does not match the name registered when the order was created. Your money is safe — do not transfer again." display_en is our English wording for your own staff tooling.
bank_code 002 is Bangkok Bank. Pull the authoritative list from GET /v1/pay/banks rather than hard-coding it.
verification{} — the six dimensions that decide automatic approval
It is six plain booleans. It never contains values read off the slip.
| Field | What it checks |
|---|---|
amount_exact | amount matches exactly, zero tolerance |
within_time_window | the timestamp on the slip falls inside the order's window |
sender_bank_match | the sending bank matches the one registered |
sender_account_match | the sending account number matches the one registered |
sender_name_match | the sender name matches the one registered |
receiver_match | the destination account matches the one the system assigned |
An exact payment needs all six booleans. When the other five pass but the amount differs, both directions close automatically: a short-pay settles at A=V<X with partial:true, settled_via:'auto'; a same-slip overpayment settles at the ordered amount with overpayment{}. An amount mismatch alone never opens review.
Why we never tell you the actual name on the slip
Because the real payer may be a different person from the one the merchant registered (a husband transferring for his wife) — that value is a third party's personal data. All we can send is matched: false, plus a masked copy of the name you supplied yourself (registered_name_masked). The practical consequence: your team works the case from the slip the customer sends you directly, not from our payload.
RECEIVER_MISMATCH is the one reason that carries no detail at all — telling somebody "it should have gone to account xxx1234" confirms a stranger's account details to a person who already transferred to the wrong place.
Viewing the slip
GET /v1/pay/deposits/{id}/slipReturns the image itself (authentication required, and the order has to be inside your account's scope).
There is no presigned URL
We never emit a storage URL. A leaked URL means anyone can download a customer's slip. If your web page needs to show the image, proxy it through your own backend. Do not embed the URL in the page.
Reading one withdrawal
curl -sS "https://api.example-wl.com/v1/pay/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=8f2c1ab6e0d74f9b3c5a7e1d0f6b8a4c2e9d7b5a3c1f0e8d6b4a2c0e8f6d4b2a"{
"id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
"object": "withdrawal_order",
"version": 7,
"status": "partially_filled",
"amount_total_minor": 1000000,
"fee_total_minor": 3000,
"filled_minor": 400000,
"remaining_minor": 600000,
"refunded_minor": 0,
"fee_recognized_minor": 1200,
"settled_splits": 1,
"max_splits": 4,
"currency": "THB",
"expires_at": "2026-08-21T16:12:00.000Z",
"fills": [
{
"leg_ref": "leg_7QF2H8KM",
"amount_minor": 400000,
"fee_recognized_minor": 1200,
"settled_at": "2026-08-21T05:02:11.045Z",
"receipt_state": "awaiting_confirm"
}
]
}remaining_minor is not always amount_total_minor − filled_minor
The real formula is remaining = amount_total − filled − reserved. reserved is the part somebody is currently transferring but has not settled. An order with filled_minor of 400000 and another 200000 in flight reports remaining_minor of 400000, not 600000.
Searching in bulk
GET /v1/pay/deposits?status=under_review&updated_after=2026-08-21T00:00:00.000Z&cursor=&limit=200
GET /v1/pay/withdrawals?updated_after=2026-08-21T00:00:00.000Z&cursor=&limit=200Use this instead of polling orders one at a time. The cursor rules are in Reports and reconciliation.
Always have customers quote the deposit_id
Your end-user screens must show deposit_id / withdrawal_id with a copy button. We cannot search your merchant_reference across systems, and orders that ran through a fallback route carry no other reference number at all.
Extending the timeout
A deposit still in awaiting_payment whose customer is about to miss the deadline can be extended from the back office.
curl -sS -X POST "https://api.example-wl.com/v1/bo/transactions/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/add-timeout" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
-H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
-H "Idempotency-Key: bo-timeout-dep01K3M7Y9-01"Employee OIDC (transactions:write), a step-up token bound to this request, and Idempotency-Key are all mandatory. The actor comes from OIDC sub; never supply an employee id yourself. This endpoint does not accept merchant API-key/HMAC authentication.
What to know before you press it
| Point | Detail |
|---|---|
Only available during awaiting_payment | once a slip has arrived it is too late — the order has moved onto the verification path |
| The new value is clamped by the policy of the tier above | ask for 60 minutes, get 20 ⇒ 🔴 read the expires_at that comes back; never compute it yourself |
🔴 Orders with liquidity_source of external_gateway cannot be extended | their expires_at comes from the route actually in use (as short as ~4 minutes). We have no authority to stretch it. |
| The late-slip window moves with it | it is measured from the new expires_at ⇒ extending the timeout also moves late_slip_accepted_until |
Never hardcode the countdown in your front end
Always take expires_at from the response, and re-sync every time the user returns to the page (they switched to a banking app and came back). A countdown hardcoded to 15 minutes is wrong the moment an order runs through a route with a shorter TTL.
Resolving an order stuck in under_review
POST /v1/bo/transactions/{id}/update-matching has been removed and is not a callable route because it had no request contract that could say unambiguously what was being changed. Use the three fully specified endpoints below for the real situation instead. Approval or rejection still happens only through the staff review-decision endpoint.
1. Attach more evidence (does not reset the status)
curl -sS -X POST "https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/evidence" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749900" \
-H "PG-Nonce: 01JQA5TH9C7E1G3J5L7N9Q1S3V" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: evid-dep01K3M7Y9-01" \
-H "PG-Signature: v1=1a3c5e70b9d2f486a0c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7" \
-H "Content-Type: application/json" \
-d '{
"kind": "bank_statement_line",
"reference": "merchant-ledger-2026-08-21#line-004921",
"note": "Merchant-held ledger line confirming the THB 5,000 transfer"
}'This endpoint accepts reference-only JSON, never a file or multipart body. kind is a closed enum: bank_statement_line · chat_transcript · id_document_ref · other (id_document_ref is a reference already held by the merchant, not an identity-card image). The evidence joins the existing case; it does not reset the state or restart the queue clock.
2. Report the actual amount for a case already in review (compatibility)
A clean short-pay with all five non-amount checks valid already settles automatically, so do not call this endpoint for it. This compatibility path is only for an order that remains under_review because of another real non-amount, ambiguity, or policy reason and has a bank-verified 0<V<X.
curl -sS -X POST "https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/force-complete" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749950" \
-H "PG-Nonce: 01JQA5TK1E9G3J5L7N9Q1S3V5X" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: forcecomplete-dep01K3M7Y9" \
-H "PG-Signature: v1=9f7d5b3a1c0e8f6d4b2a0c8e6f4d2b0a8c6e4f2d0b8a6c4e2f0d8b6a4c2e0f8d" \
-H "Content-Type: application/json" \
-d '{
"verified_amount_minor": 480000,
"note": "รายการติดตรวจชื่อผู้โอน ลูกค้าโอน 4,800 บาทแทน 5,000 บาท แนบหลักฐานเพื่อรับยอดจริงหลังตรวจชื่อ"
}'(The Thai note reads: "Customer transferred THB 4,800 instead of THB 5,000; slip attached, real amount confirmed against the slip.")
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 7,
"status": "under_review",
"is_terminal": false
}202 is not an approval
The name force-complete is easy to misread. It is a request for staff to decide, not an instruction to close the order. The status that comes back is under_review and the money has not been credited. Conditions: the order must already be in review for another reason · authoritative evidence must derive 0<V<X · note must be at least 20 characters. If the clean short-pay already closed automatically, this endpoint returns 409 deposit_not_reviewable.
After staff resolves the other reason and approves the reviewed short-pay, you receive deposit.settled with partial:true, settled_via:'review', and ordered_amount_minor. Here amount_received_minor (A) equals bank-verified V and is below X; credit the user with A, not with the ordered amount. A clean short-pay sends the same financial shape with settled_via:'auto' and never enters this step.
3. Have staff decide an External route conflict
This is not a generic review approval and does not settle a deposit. It applies only to a deposit review whose sole reason is provider_conflict, whose authoritative External route exists, whose latest projection says can_decide=true, and whose live lease belongs to the current White Label or Agent employee. A Merchant cannot decide its own item; slips, short-pays, overpayments, mixed reasons, and Owner Tier-4 keep their separate workflows.
The Brand UI starts request-bound OIDC step-up from a user gesture, then uses only its same-origin cookie session. The browser must never hold or send an OIDC bearer token:
const reviewId = '40000000-0000-4000-8000-000000000001'
const body = {
decision: 'approve',
reason_code: 'EXTERNAL_RESULT_ACCEPTED',
decision_note: 'Checked the External route result and supporting evidence for this exact review.',
}
await fetch(`/v1/bo/reviews/${reviewId}/decisions`, {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'X-PG-BO-Client': '1',
'X-PG-Step-Up-Token': requestBoundAssertion,
'If-Match': '"3"',
'Idempotency-Key': 'review-decision-4000-approve-01',
},
body: JSON.stringify(body),
})Reject uses the same URL with decision: 'reject'. The proof binds review.approve or review.reject, so the outcome cannot be swapped after step-up. Success resolves only the review task, clears its lease, and returns ETag plus Idempotent-Replay; it does not mutate the deposit, route, evidence, match, reservation, journal, ledger, fee, or reserve. Legacy /approve and /reject paths are superseded and have no runtime route.
What you cannot edit — and what to do instead
| You want to change | Possible | Do this instead |
|---|---|---|
| The sending account of a deposit | ❌ | not transferred yet → cancel and create a new order · already transferred → upload the slip as normal and let it go to review |
| The amount of a deposit | ❌ | not transferred → cancel and create a new order · amount-only mismatch after transfer → wait for automatic deposit.settled · use force-complete only when another reason already put the order in review |
The amount/beneficiary of a pending_approval withdrawal | ❌ | cancel and create a new order (the money is already locked against the old amount) |
| The destination account of a deposit | ❌ | the system assigns it, not you, and it cannot be changed |
merchant_reference | ❌ | unique per account, set once at creation |
An order that already has a slip cannot be cancelled
POST /v1/pay/deposits/{id}/cancel returns 409 deposit_not_cancelable. The reason is blunt: somebody really did transfer money. Cancelling the order does not bring it back. The correct path is to let it run to settled or rejected, and if money has to go back, go through Claims.
Unblocking a user who cannot create an order
A user who is over the open-order limit gets 409 too_many_open_deposits with details.open_deposit_ids[].
curl -sS -X POST "https://api.example-wl.com/v1/bo/users/u_88213/clear-block" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
-H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
-H "Idempotency-Key: bo-clear-block-u88213-01" \
-H "Content-Type: application/json" \
-d '{
"reason_code": "OPEN_ORDERS_REVIEWED",
"note": "Reviewed all open orders and closed every stale order"
}'This command requires employee OIDC (users:write), request-bound step-up, and Idempotency-Key. The actor comes from OIDC sub and is written to an immutable audit log. Merchant API-key/HMAC authentication is not accepted.
Close the open orders before you clear the block
The limit exists to stop a user from opening several orders for the same amount and then making one transfer — which turns the whole set into ambiguous orders that cannot be matched. The right move is to cancel the stale orders the user is not going to pay, from open_deposit_ids[], rather than lifting the limit.
Status map for your support team
status | Where the money is | What to tell the customer |
|---|---|---|
awaiting_liquidity | not transferred | we are finding a route, do not transfer yet 🔴 no QR |
awaiting_payment | not transferred | go ahead, and transfer only from the registered account |
slip_processing | transferred, being checked | we are checking the slip 🔴 do not release goods |
under_review | transferred, being checked | use review.reasons[0].display_th verbatim, plus "your money is safe, do not transfer again" |
settled | credited | done |
settled + overpayment{} | credited at the ordered amount; excess is in a claim | the order succeeded · show the excess/claim id and ask the customer to contact support · do not say it was already refunded |
expired | usually not transferred | if is_terminal is false → the "I already transferred but it expired" button is still there |
canceled | usually not transferred | same as expired when late_slip_accepted_until is set |
rejected | not credited | use rejection.display_th and show the deposit_id |
reversed | debited back | you need a handler from day one; this event cannot be unsubscribed |
Check is_terminal, not the status name
expired and canceled are not the end while late_slip_accepted_until is still set. A team that closes the case the moment it sees canceled will get a deposit.settled for that closed order twenty minutes later.
Next
- Claims — the customer transferred but there is no order to hold it
- Deposit statuses — the full meaning of every status
- The review queue and the options you have
- Logs and audit trail — replaying the request/response history of this order
- Reports and reconciliation — bulk search with cursors
