Skip to content

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.*) ​

EventWhen it firesOptionalWhat is different in the payload
deposit.createdEvery 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_liquidityNo 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.matchedA 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_updatedThe status of one or more legs changed✅legs[{leg_ref, sequence, total, leg_status}]
deposit.slip_receivedThe slip has been accepted (the 202 was already returned)✅slip.received_at · slip.is_late · 🔴 no verification yet — do not release goods
deposit.slip_rejectedThe 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_reviewA 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.settledCredited, 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.overpaidAn 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.expiredTime ran out with no slip✅late_slip_accepted_until · is_terminal · expired_reason ∈ no_payment
deposit.canceledCanceled 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.rejectedAn operator decided not to accept this slip✅rejection.code + rejection.display_th / display_en
deposit.reversedAn order that had settled was reversed🔴 noreversal.reason_code · reversal.reverses_balance_transaction_id · reversal.balance_transaction_id

Withdrawal side (withdrawal.*) ​

EventWhen it firesOptionalWhat is different in the payload
withdrawal.createdA withdrawal order was created✅requires_approval · reserved_balance_transaction_id (the amount + fee that was held)
withdrawal.matchedEvery time someone reserves an amount to pay you✅matched_legs · reserved_minor · 🔴 no id of the counterparty deposit
withdrawal.filledOne 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.settledThe full amount has arrived✅fills[] summarising every chunk · fee_total_minor · dust_absorbed
withdrawal.expiredTime ran out with an amount still outstanding✅filled_minor / refunded_minor / fee_recognized_minor · released_balance_transaction_id
withdrawal.canceledThe remaining part was canceled✅canceled_by · refunded_minor
withdrawal.rejectedRejected while awaiting approval✅rejection.code + display
withdrawal.receipt_confirmedThe withdrawer confirmed receiving that chunk✅fill.leg_ref · confirmed_by ∈ withdrawer | auto
withdrawal.disputedThe 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_resolvedThe dispute has been decided✅dispute{id, outcome ∈ confirmed | reversed}
withdrawal.fill_reversedA chunk that was already paid out got reversed🔴 nofill.leg_ref · reversal.reason_code · replacement_withdrawal_id (if any)

Wallet side (balance.*) ​

EventWhen it firesOptionalWhat is different in the payload
balance.creditedEvery time money enters your wallet✅data.object is a balance_transaction (immutable — no version) · bucket_to · available_at
balance.cleared72 hours are up; the amount moves from pending-clearing to withdrawable✅cleared_from_balance_transaction_id · bucket_from: 'pending_clearing' · bucket_to: 'available'
balance.debitedEvery time money leaves your wallet🔴 notype ∈ 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 ​

EventWhen it firesOptionalWhat is different in the payload
webhook.key.rotatingAnnounces the new kid 7 days ahead🔴 nonext_kid · next_public_key · next_raw_public_key · activates_at — ⚠️ signed with the current key, not the new one
webhook.endpoint.disabledThe system disabled an endpoint after 24 hours of continuous failure🔴 noDelivered 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:

EventWhen it firesWhat is certain
beneficiary.verifiedThe 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.rejectedThe check failedcode ∈ NAME_MISMATCH | ACCOUNT_NOT_FOUND | BANK_UNAVAILABLE
claim.updatedAn open claim, including an overpayment, progressedClaim.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.

eventwhat changeswhy there is no new status
deposit.awaiting_liquidityliquidity{}The status is still awaiting_liquidity.
deposit.under_review (second delivery)review.escalated = trueWaiting too long must not move money.
deposit.overpaidAttaches the latest overpayment{} from an additional transferThe original order settled correctly and must not be rolled back; a same-slip flag is already in deposit.settled.
withdrawal.matchedreserved_minorReserving an amount must not advance the status.
withdrawal.disputedAttaches 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.

json
{
  "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_th always 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 > 1 is normal ⇒ the UI must say "step 1 of 2", not pick legs[0] and show a single transfer.
  • 🔴 Count down from the expires_at in 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_payload must not be logged, stored in your database, or forwarded — render it and drop it. The safer path is to send the user to checkout_url.

2. deposit.under_review — bank checking disabled + sender name mismatch ​

json
{
  "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_th strings 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_en is there for English-speaking ones.

What to do with this event

  • Render review.reasons[0].display_th verbatim — 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 | extended can be shown to the user directly as a rough wait time.

3. deposit.settled — keep the verified transfer separate from the accepted amount ​

json
{
  "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 exceeds A only 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_minor must 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_at is when this amount becomes withdrawable (T+72h). Until then it sits in pending_clearing ⇒ creating a withdrawal returns 402 insufficient_balance even though the dashboard shows a number.
  • 🔴 is_terminal: false, because settled can still move to reversed · 🔴 do not use is_terminal to decide whether to credit — the only condition is status === 'settled'.
  • A balance.credited follows this event (the destination bucket is pending_clearing), and 72 hours later a balance.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.

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

json
{
  "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.filled is not the end — close the order only when status = 'settled' and remaining_minor = 0.
  • 🔴 Read the amounts from data.object.filled_minor / remaining_minor after the version guard · never SUM() 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_ref exists 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_minor we 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 ​

json
{
  "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_th is 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, no previous_attributes — balance_transaction is immutable ⇒ dedupe by id and accumulate, never overwrite.
  • reverses_balance_transaction_id points back to the balance_transaction_id from the original deposit.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 separate balance_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_after as 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 from GET /v1/pay/balance.

Reason-code dictionary ​

Every entry in review.reasons[] looks like this:

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

codecauseactionable_bydetail you get
AMOUNT_MISMATCHV<X or V>X only alongside another real review reason; an amount-only mismatch settles automaticallyend_userexpected_minor, observed_minor
TIME_WINDOWThe slip timestamp falls outside the order's windowsupportwindow_start, window_end, slip_time
SENDER_BANK_MISMATCHThe sending bank is not the one registeredend_userregistered_bank_code
SENDER_ACCOUNT_MISMATCHThe sending account number is not the one registeredend_userregistered_account_masked
SENDER_NAME_MISMATCHThe sender name is not the one registeredend_userregistered_name_masked, matched
RECEIVER_MISMATCHThe transfer went to the wrong destination accountend_user🔴 no detail at all
BANK_DISABLEDSlip checking for that bank is temporarily switched offsupportbank_code (the sender's own bank)
DUPLICATE_SLIPThis slip has already been usedend_user—
SLIP_INVALIDThe slip-checking system reported the slip as unusablesupport—
SLIP_UNREADABLEThe image was unreadable before the 6 checks even ranend_user— (arrives on deposit.slip_rejected, not under_review)
AMBIGUOUS_MATCHThe slip matched more than one ordersupport—
PROVIDER_UNAVAILABLEThe slip-checking system is temporarily unavailablesupport—
SENDER_ACCOUNT_UNAVAILABLEThe slip-checking system returned no sender account detailssupport—
LATE_SLIPThe slip arrived after the order expiredsupportoriginal_expires_at
CANCELED_ORDER_SLIPA slip for an order that had already been canceledsupportcanceled_at
ALLOCATION_CONFLICTThat portion has already been matched to another ordersupportdispute_id
SETTLE_CONFLICTThe crediting step stalled or failed for more than 15 minutessupport—
ROUTE_VERIFICATION_PENDINGThe fallback route needs an extra checksupport—
LATE_ROUTE_CONFIRMATIONThe fallback route confirmed after the order was already closedsupport—
RISK_HOLDThe risk system placed a holdsupport—
NO_SLIP_IMAGEThe 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_MISMATCH never carries a detail. 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.
  • detail may 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 ​

valuemeaningwhat the UI should do
end_userThe 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.
supportOnly an operator can move this forward; the user can do nothingShow 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 nextwhen
Order is not guaranteed — version guard🔴 Before you write your first switch.
Verify the Ed25519 signatureBefore you trust any payload at all.
Retry and endpoint outagesYou want to know what follows a non-2xx response.
Deposit statusesYou need to map statuses to end-user screens.
Receiving multiple fillsYou are building the withdrawal side.
Field dictionaryYou are not sure which field name is the real one.

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