Full event catalogue
This is the only list. There is no event that is not in these tables.
type is an open enum
New event names will appear one day, and that does not count as a breaking change. Every switch (event.type) needs a default: that answers 200 and just records the event — not one that throws.
Event tables
Deposit side (deposit.*)
| Event | When it fires | Optional | What is different in the payload |
|---|---|---|---|
deposit.created | Every time an order is created successfully — on both the 201 path and the 202 path | ✅ | data.previous_attributes = null (the only event where it is always null) · request.idempotency_key · data.object.status holds the real value (awaiting_payment or awaiting_liquidity) |
deposit.awaiting_liquidity | No counterparty match yet · fires after created as an attribute change | ✅ | liquidity.wait_started_at · liquidity.estimated_wait_ms · liquidity.retry_interval_ms · liquidity.suggested_amounts_minor[] |
deposit.matched | A destination to transfer to is ready | ✅ | legs[] (QR + destination + per-leg expires_at) · liquidity_source · leg_count — the only event that carries a QR |
deposit.leg_updated | The status of one or more legs changed | ✅ | legs[{leg_ref, sequence, total, leg_status}] |
deposit.slip_received | The slip has been accepted (the 202 was already returned) | ✅ | slip.received_at · slip.is_late · 🔴 no verification yet — do not release goods |
deposit.slip_rejected | The image is unreadable, we need another one | ✅ | retryable: true · rejection.code = 'SLIP_UNREADABLE' · attempts_left — 🔴 this is not a rejection of the money |
deposit.under_review | A non-amount check failed / policy hold / bank checking disabled / duplicate slip / ambiguous match; an amount mismatch alone never enters this queue | ✅ | review.reasons[] · review.opened_at · review.escalated · review.four_eyes_required · review.estimated_resolution_bucket · verification{} with 6 booleans |
deposit.settled | Credited, including clean short-pay and same-slip overpayment | ✅ | complete financial snapshot + overpayment always (null when none, object on excess) · clean short-pay sends A=V<X, partial:true, settled_via:'auto'; same-slip overpay emits this one event only |
deposit.overpaid | An additional transfer arrived after settlement | ✅ | Full settled snapshot (ordered/transferred/received/fee/credited/partial) with original amounts unchanged, plus overpayment{claim_id, cause:'additional_transfer', amount_minor, slip_received_at, status, action_required} — 🔴 status stays settled; no automatic refund |
deposit.expired | Time ran out with no slip | ✅ | late_slip_accepted_until · is_terminal · expired_reason ∈ no_payment |
deposit.canceled | Canceled before the transfer — by you, by the end user, or because the system ran out of ways to source liquidity | ✅ | canceled_by ∈ merchant | end_user | system · cancel_reason_code · late_slip_accepted_until (may be null) · is_terminal |
deposit.rejected | An operator decided not to accept this slip | ✅ | rejection.code + rejection.display_th / display_en |
deposit.reversed | An order that had settled was reversed | 🔴 no | reversal.reason_code · reversal.reverses_balance_transaction_id · reversal.balance_transaction_id |
Withdrawal side (withdrawal.*)
| Event | When it fires | Optional | What is different in the payload |
|---|---|---|---|
withdrawal.created | A withdrawal order was created | ✅ | requires_approval · reserved_balance_transaction_id (the amount + fee that was held) |
withdrawal.matched | Every time someone reserves an amount to pay you | ✅ | matched_legs · reserved_minor · 🔴 no id of the counterparty deposit |
withdrawal.filled | One chunk of money actually arrived | ✅ | fill{leg_ref, sequence, amount_minor, fee_recognized_minor, settled_at, balance_transaction_id, auto_confirm_at} · filled_minor · remaining_minor · settled_splits / max_splits — 🔴 the only event that fires several times for one order |
withdrawal.settled | The full amount has arrived | ✅ | fills[] summarising every chunk · fee_total_minor · dust_absorbed |
withdrawal.expired | Time ran out with an amount still outstanding | ✅ | filled_minor / refunded_minor / fee_recognized_minor · released_balance_transaction_id |
withdrawal.canceled | The remaining part was canceled | ✅ | canceled_by · refunded_minor |
withdrawal.rejected | Rejected while awaiting approval | ✅ | rejection.code + display |
withdrawal.receipt_confirmed | The withdrawer confirmed receiving that chunk | ✅ | fill.leg_ref · confirmed_by ∈ withdrawer | auto |
withdrawal.disputed | The withdrawer reports that the money never arrived | ✅ | dispute{id, reason_code, amount_minor, opened_at, respond_by, evidence_url} · funds_on_hold: true · 🔴 status does not change |
withdrawal.dispute_resolved | The dispute has been decided | ✅ | dispute{id, outcome ∈ confirmed | reversed} |
withdrawal.fill_reversed | A chunk that was already paid out got reversed | 🔴 no | fill.leg_ref · reversal.reason_code · replacement_withdrawal_id (if any) |
Wallet side (balance.*)
| Event | When it fires | Optional | What is different in the payload |
|---|---|---|---|
balance.credited | Every time money enters your wallet | ✅ | data.object is a balance_transaction (immutable — no version) · bucket_to · available_at |
balance.cleared | 72 hours are up; the amount moves from pending-clearing to withdrawable | ✅ | cleared_from_balance_transaction_id · bucket_from: 'pending_clearing' · bucket_to: 'available' |
balance.debited | Every time money leaves your wallet | 🔴 no | type ∈ withdrawal_reserved | withdrawal_settled | reversal | fee | adjustment · balance_after{} |
balance.cleared is not new money arriving
It is a bucket move of money you already have, from pending_clearing → available.
A merchant that increments a balance on each event will end up with double the real amount if this one is counted as an inflow. That is exactly why we do not fire a second balance.credited at T+72h.
System side (webhook.*) — neither can be turned off
| Event | When it fires | Optional | What is different in the payload |
|---|---|---|---|
webhook.key.rotating | Announces the new kid 7 days ahead | 🔴 no | next_kid · next_public_key · next_raw_public_key · activates_at — ⚠️ signed with the current key, not the new one |
webhook.endpoint.disabled | The system disabled an endpoint after 24 hours of continuous failure | 🔴 no | Delivered to your other healthy endpoint group + an email to the technical contact |
Events whose payload is not yet specified in the contract
These three are on the list of event names you must handle, and their origin is clear, but there is no full payload spec yet:
| Event | When it fires | What is certain |
|---|---|---|
beneficiary.verified | The withdrawal beneficiary account passed the bank name check (usually < 30 s) | You must wait for this before creating a withdrawal with that beneficiary_id |
beneficiary.rejected | The check failed | code ∈ NAME_MISMATCH | ACCOUNT_NOT_FOUND | BANK_UNAVAILABLE |
claim.updated | An open claim, including an overpayment, progressed | Claim.version always increases; the normal path is open/end_user → refund_pending/none → refunded/refund_issued; terminal payout failure returns to under_review/support with no journal; an original-transfer return may move return_pending_payout/none → returned_by_bank/transfer_returned, which is not a platform payout · never auto-refunded |
⇒ Write your handler so these can safely fall through to default:, then read the real state from the relevant endpoint.
Events that do not change status (attribute changes)
These five fire while the status stays exactly where it was. A handler that decides from event.type alone will misread them immediately.
| event | what changes | why there is no new status |
|---|---|---|
deposit.awaiting_liquidity | liquidity{} | The status is still awaiting_liquidity. |
deposit.under_review (second delivery) | review.escalated = true | Waiting too long must not move money. |
deposit.overpaid | Attaches the latest overpayment{} from an additional transfer | The original order settled correctly and must not be rolled back; a same-slip flag is already in deposit.settled. |
withdrawal.matched | reserved_minor | Reserving an amount must not advance the status. |
withdrawal.disputed | Attaches dispute{} | The dispute is about an order that already settled; adding a status would mean stepping back out of a terminal state. |
Every one of them still bumps version
Attribute changes get a new version too ⇒ your version guard will not discard them. If they did not bump, you would have no way of knowing an escalation happened.
There is no deposit.auto_approved event, and there never will be
An order sitting in the operator queue past the threshold is not approved automatically. We send deposit.under_review again, with review.escalated: true and a new version.
No path in this system turns a timeout into settled.
Six real bodies
All six are valid, parseable JSON. Amounts are integers in satang. Timestamps are UTC, end in Z, with 3-digit milliseconds.
1. deposit.matched — 2 legs over the fallback route
This is the only event that carries a QR. Note that expires_at is shorter than it was at order creation.
{
"id": "evt_01K3M7YB4C9QDR2XKF0T8VN3PA",
"object": "event",
"type": "deposit.matched",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:10:52.104Z",
"sequence": 88207,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"request": {
"id": "req_01K3M7YA9C2NVRT4HB0XKDF8QS",
"idempotency_key": "order-9981-topup"
},
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 3,
"status": "awaiting_payment",
"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:14:41.000Z",
"checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
"liquidity_source": "external_gateway",
"leg_count": 2,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 300000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560012345530376454063000.005802TH6304A1B2",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
},
{
"leg_ref": "leg_7QF2H8KN",
"sequence": 2,
"amount_minor": 200000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560099887530376454062000.005802TH6304C3D4",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/2.png",
"receiver": {
"bank_code": "004",
"bank_name_th": "ธนาคารกสิกรไทย",
"account_number_masked": "xxx-x-x9887",
"account_name_masked": "ว*** ป***"
}
}
],
"sender": {
"bank_code": "002",
"account_number_masked": "xxx-x-x5567",
"account_name_th_masked": "ป*** ส***"
},
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
},
"previous_attributes": {
"version": 2,
"status": "awaiting_liquidity",
"legs": null,
"liquidity_source": null,
"leg_count": null,
"expires_at": "2026-08-21T04:30:47.902Z"
}
}
}Bank codes in this body:
014= Siam Commercial Bank,004= Kasikornbank,002= Bangkok Bank.bank_name_thalways carries the Thai name, because it is meant to be shown to a Thai end user as-is.
What to do with this event
- Render every leg —
legs.length > 1is normal ⇒ the UI must say "step 1 of 2", not picklegs[0]and show a single transfer. - 🔴 Count down from the
expires_atin this event, never a hardcoded value. The original was 20 minutes; the new one is about 4. liquidity_source: "external_gateway"is context only · 🔴 never use it to decide whether to credit.- 🔴
qr_payloadmust not be logged, stored in your database, or forwarded — render it and drop it. The safer path is to send the user tocheckout_url.
2. deposit.under_review — bank checking disabled + sender name mismatch
{
"id": "evt_01K3M9F2XN8VQKR4T0BDHY7WZE",
"object": "event",
"type": "deposit.under_review",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:19:08.774Z",
"sequence": 88231,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 6,
"status": "under_review",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"review": {
"opened_at": "2026-08-21T04:19:08.601Z",
"escalated": false,
"four_eyes_required": false,
"estimated_resolution_bucket": "under_1h",
"reasons": [
{
"code": "BANK_DISABLED",
"display_th": "ขณะนี้ระบบตรวจสอบสลิปของธนาคารนี้ปิดรับชั่วคราว เจ้าหน้าที่กำลังตรวจสอบรายการของคุณด้วยตนเอง เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "Automatic slip checking for this bank is temporarily unavailable. Our team is reviewing your transfer manually.",
"actionable_by": "support",
"detail": { "bank_code": "002" }
},
{
"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
},
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
},
"previous_attributes": {
"version": 5,
"status": "slip_processing",
"review": null,
"verification": null
}
}
}Both
display_thstrings are the end-user copy we already wrote for you. The first reads "Automatic slip checking for this bank is temporarily paused. An operator is reviewing your transfer by hand. Your money is safe — do not transfer again." The second 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." Show the Thai to Thai end users;display_enis there for English-speaking ones.
What to do with this event
- Render
review.reasons[0].display_thverbatim — we already wrote the copy, you do not have to.reasons[]is ordered by importance, so the first entry is the one to show when you only have one line of screen space. - 🔴 Do not release goods, and do not show a "transfer again" button. The money has already been sent; asking for a second transfer makes the user pay twice.
verification{}is six plain booleans. We never emit the name or account number read off the slip, because the person who actually transferred may be someone other than the one you registered (paying from a spouse's account) — that would be a third party's personal data.estimated_resolution_bucket ∈ under_15m | under_1h | under_4h | extendedcan be shown to the user directly as a rough wait time.
3. deposit.settled — keep the verified transfer separate from the accepted amount
{
"id": "evt_01K3M9ZZ2P7QW2NHT0RXKD5V8C",
"object": "event",
"type": "deposit.settled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:21:11.402Z",
"sequence": 88240,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 9,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"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,
"overpayment": null,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"partial": false,
"settled_via": "review",
"liquidity_source": "external_gateway",
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"settled_at": "2026-08-21T04:21:11.208Z",
"clearing_available_at": "2026-08-24T04:21:11.208Z",
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
},
"previous_attributes": { "version": 6, "status": "under_review" }
}
}Never swap these numbers
transferred_amount_minor→ 500000 is the bank-verified settlement evidence. It exceedsAonly on same-slip overpayment; a later additional transfer does not make this field cumulative.- Credit the end user with
amount_received_minor→ 500000 (THB 5,000.00), the amount accepted into this order. - The merchant wallet moves by
credited_amount_minor→ 492500 (THB 4,925.00). fee_total_minor→ 7500 ·amount_received_minor − fee_total_minor = credited_amount_minormust hold every time ⇒ assert it in your daily reconcile job.
Crediting the end user with credited_amount_minor shortchanges them by the fee on every single order.
On same-slip overpayment, transferred_amount_minor exceeds amount_received_minor=ordered_amount_minor, and the same object carries overpayment{claim_id,cause:'same_transfer_excess',amount_minor,action_required:'contact_support'}. Still credit once using amount_received_minor, and show the claim so the user can contact support.
Other things this body tells you
settled_via: "review"means an operator handled this order rather than automation. Useful for your own statistics; nothing to show the user.clearing_available_atis when this amount becomes withdrawable (T+72h). Until then it sits inpending_clearing⇒ creating a withdrawal returns402 insufficient_balanceeven though the dashboard shows a number.- 🔴
is_terminal: false, becausesettledcan still move toreversed· 🔴 do not useis_terminalto decide whether to credit — the only condition isstatus === 'settled'. - A
balance.creditedfollows this event (the destination bucket ispending_clearing), and 72 hours later abalance.cleared.
4. deposit.canceled — the system ran out of routes to transfer through
Other gateways usually surface this as an expired order or an HTTP 503. We always create the order, then close it with canceled.
{
"id": "evt_01K3N2QK8M4TVR7B0HDZX2C5NP",
"object": "event",
"type": "deposit.canceled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T05:02:47.559Z",
"sequence": 88318,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "dep_01K3N2QG5W8VRT0BHKZQ7M4YXD",
"object": "deposit_order",
"version": 4,
"status": "canceled",
"is_terminal": true,
"merchant_reference": "ORD-10042",
"amount_minor": 1750000,
"currency": "THB",
"created_at": "2026-08-21T05:01:22.117Z",
"canceled_by": "system",
"cancel_reason_code": "no_liquidity_available",
"late_slip_accepted_until": null,
"metadata": { "user_id": "u_90117", "order_id": "ORD-10042" }
},
"previous_attributes": { "version": 3, "status": "awaiting_liquidity" }
}
}canceled does not always mean finished — check two fields
This one really is finished, because the user never had a destination to transfer to ⇒ is_terminal: true and late_slip_accepted_until: null.
But an order where the user saw the QR, hit cancel, and then transferred anyway (this happens several times a week) arrives with is_terminal: false and a real timestamp in late_slip_accepted_until ⇒ you must keep the slip-upload button available instead of closing the order.
Code that reads case 'deposit.canceled': failOrder() will cost users real money. Check is_terminal, not the status name.
The same rule applies to deposit.expired, without exception.
5. withdrawal.filled — chunk 1 of several
This THB 10,000 withdrawal received its first chunk of THB 4,000. THB 6,000 is still outstanding.
{
"id": "evt_01K3NB4T7QW2VRH0DZXK5M8CNP",
"object": "event",
"type": "withdrawal.filled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T06:48:03.221Z",
"sequence": 88477,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "wdr_01K3NAX2R7T0QWVB5HDZKM9C4E",
"object": "withdrawal_order",
"version": 7,
"status": "partially_filled",
"merchant_reference": "WD-4471",
"amount_total_minor": 1000000,
"fee_total_minor": 3000,
"fee_recognized_minor": 1200,
"filled_minor": 400000,
"remaining_minor": 600000,
"reserved_minor": 0,
"refunded_minor": 0,
"settled_splits": 1,
"max_splits": 4,
"currency": "THB",
"expires_at": "2026-08-21T10:44:58.000Z",
"metadata": { "user_id": "u_77320", "payout_id": "WD-4471" }
},
"fill": {
"leg_ref": "leg_5TQ0WH2E",
"sequence": 1,
"amount_minor": 400000,
"fee_recognized_minor": 1200,
"settled_at": "2026-08-21T06:48:03.004Z",
"balance_transaction_id": "btxn_01K3NB4T5RW2VRH0DZXK5M8CNP",
"auto_confirm_at": "2026-08-24T06:48:03.004Z"
},
"previous_attributes": {
"version": 6,
"status": "open",
"filled_minor": 0,
"remaining_minor": 1000000,
"fee_recognized_minor": 0,
"settled_splits": 0
}
}
}One withdrawal = many webhooks
A THB 10,000 withdrawal can produce withdrawal.matched ×3 + withdrawal.filled ×3 + withdrawal.settled ×1 = 7 deliveries for one order.
- 🔴
withdrawal.filledis not the end — close the order only whenstatus = 'settled'andremaining_minor = 0. - 🔴 Read the amounts from
data.object.filled_minor/remaining_minorafter the version guard · neverSUM()your own fill table (events may not all have arrived, so you get a number that is too low) and never+=(a duplicate delivery adds twice). - Your fill table keyed on
leg_refexists for timeline and audit only. Do not let it drive business logic. - 🔴 Do not compute fees per chunk yourself. The fixed part of the fee belongs to the order, not to each chunk ⇒ splitting into 4 does not mean 4× the fixed fee. Use the
fee_recognized_minorwe send. - The UI must be a multi-line timeline, not a single status bar.
auto_confirm_at is when this chunk is treated as confirmed by the recipient if nobody objects (72 hours). Before then a withdrawal.disputed can still arrive.
6. balance.debited — a reversal, on an event you cannot turn off
{
"id": "evt_01K3PB6QW2ZR9YT5N4KDXCVH8M",
"object": "event",
"type": "balance.debited",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T09:41:20.006Z",
"sequence": 89402,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "btxn_01K3PB6QW1M8HN0DZ2XKQ7RV4T",
"object": "balance_transaction",
"type": "reversal",
"direction": "debit",
"amount_minor": 492500,
"currency": "THB",
"bucket_from": "pending_clearing",
"bucket_to": null,
"balance_after": {
"available_minor": 1284300,
"pending_clearing_minor": 0,
"reserved_minor": 200000
},
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"reverses_balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"reason_code": "SLIP_INVALIDATED_AFTER_SETTLEMENT",
"reason_display_th": "รายการถูกยกเลิกหลังจากตรวจพบว่าสลิปไม่ถูกต้อง",
"created_at": "2026-08-21T09:41:19.884Z"
}
}
}
reason_display_this end-user copy: "The order was canceled after the slip was found to be invalid."
How this body differs from every other one
- No
version, noprevious_attributes—balance_transactionis immutable ⇒ dedupe byidand accumulate, never overwrite. reverses_balance_transaction_idpoints back to thebalance_transaction_idfrom the originaldeposit.settled(example 3) ⇒ you can reverse your own side precisely, without guessing. The system enforces one reversal per order.- 492500 is the original
credited_amount_minor(net of fees). The fee that had already been recognised comes back as a separatebalance_transaction(type: "fee"). bucket_from: "pending_clearing"— a reversal within 72 hours is taken from the amount still waiting to clear, not from your withdrawable balance ⇒ your wallet cannot go negative. That is the entire reason the pending-clearing bucket exists.- 🔴 Do not use
balance_afteras your own running balance — it is a snapshot taken at that moment, and if events arrive out of order you will overwrite with a stale number. Use it for cross-checking only; read the real balance fromGET /v1/pay/balance.
Reason-code dictionary
Every entry in review.reasons[] looks like this:
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "ชื่อบัญชีผู้โอนในสลิปไม่ตรงกับที่แจ้งไว้ตอนสร้างรายการ",
"display_en": "The sender name on the slip does not match the one registered for this order.",
"actionable_by": "end_user",
"detail": { "registered_name_masked": "สม*** ใจ***", "matched": false }
}We send the copy — you do not have to write it
display_th and display_en are present on every entry. Render reasons[0].display_th as-is.
Writing your own copy from code means your wording goes stale whenever we improve ours, and code is an open enum ⇒ one day you will hit a code you cannot map, and the screen will be blank.
| code | cause | actionable_by | detail you get |
|---|---|---|---|
AMOUNT_MISMATCH | V<X or V>X only alongside another real review reason; an amount-only mismatch settles automatically | end_user | expected_minor, observed_minor |
TIME_WINDOW | The slip timestamp falls outside the order's window | support | window_start, window_end, slip_time |
SENDER_BANK_MISMATCH | The sending bank is not the one registered | end_user | registered_bank_code |
SENDER_ACCOUNT_MISMATCH | The sending account number is not the one registered | end_user | registered_account_masked |
SENDER_NAME_MISMATCH | The sender name is not the one registered | end_user | registered_name_masked, matched |
RECEIVER_MISMATCH | The transfer went to the wrong destination account | end_user | 🔴 no detail at all |
BANK_DISABLED | Slip checking for that bank is temporarily switched off | support | bank_code (the sender's own bank) |
DUPLICATE_SLIP | This slip has already been used | end_user | — |
SLIP_INVALID | The slip-checking system reported the slip as unusable | support | — |
SLIP_UNREADABLE | The image was unreadable before the 6 checks even ran | end_user | — (arrives on deposit.slip_rejected, not under_review) |
AMBIGUOUS_MATCH | The slip matched more than one order | support | — |
PROVIDER_UNAVAILABLE | The slip-checking system is temporarily unavailable | support | — |
SENDER_ACCOUNT_UNAVAILABLE | The slip-checking system returned no sender account details | support | — |
LATE_SLIP | The slip arrived after the order expired | support | original_expires_at |
CANCELED_ORDER_SLIP | A slip for an order that had already been canceled | support | canceled_at |
ALLOCATION_CONFLICT | That portion has already been matched to another order | support | dispute_id |
SETTLE_CONFLICT | The crediting step stalled or failed for more than 15 minutes | support | — |
ROUTE_VERIFICATION_PENDING | The fallback route needs an extra check | support | — |
LATE_ROUTE_CONFIRMATION | The fallback route confirmed after the order was already closed | support | — |
RISK_HOLD | The risk system placed a hold | support | — |
NO_SLIP_IMAGE | The user submitted a bank-app reference number instead of a slip image ⇒ always forced into the operator queue | — | — |
Rules you need to know about detail
- 🔴
RECEIVER_MISMATCHnever carries adetail. The destination account belongs to someone else; saying "the correct one was xxx1234" would confirm a third party's account details to whoever transferred to the wrong place. detailmay contain personal data only for values you supplied yourself at order creation (sender.*), echoed back masked.- 🔴 Values read off the actual slip are never emitted — you only get
matched: false.
What actionable_by is for
| value | meaning | what the UI should do |
|---|---|---|
end_user | The user can do something (attach more evidence, use a different sending account next time) | Show the message plus the options that exist · 🔴 still no "transfer again" button. |
support | Only an operator can move this forward; the user can do nothing | Show the message plus the rough wait from estimated_resolution_bucket. |
Rejection and reversal codes
rejection.code (on deposit.rejected / withdrawal.rejected) is an open enum too, and always ships with display_th / display_en.
The current reversal.reason_code set — also open:
BANK_RECALL · ACCOUNT_FROZEN · SLIP_INVALIDATED_AFTER_SETTLEMENT · DUPLICATE_CREDIT · OPS_ERROR · MERCHANT_DISPUTE_LOST
withdrawal.fill_reversed is limited to the first three true-transfer-failure reasons. The other three — DUPLICATE_CREDIT, OPS_ERROR, and MERCHANT_DISPUTE_LOST — are deposit-only corrections: they emit deposit.reversed + balance.debited, but not withdrawal.fill_reversed, and do not change withdrawal fill totals or withdrawal-side fees.
The current cancel_reason_code set — open:
no_liquidity_available · merchant_request · end_user_request · approval_ttl_expired
Next
| read next | when |
|---|---|
| Order is not guaranteed — version guard | 🔴 Before you write your first switch. |
| Verify the Ed25519 signature | Before you trust any payload at all. |
| Retry and endpoint outages | You want to know what follows a non-2xx response. |
| Deposit statuses | You need to map statuses to end-user screens. |
| Receiving multiple fills | You are building the withdrawal side. |
| Field dictionary | You are not sure which field name is the real one. |
