State machine
This page is the map of every status that webhooks and REST responses can hand you. If you read only one page in the reference section, read this one.
The one rule you cannot forget
Only status = "settled" means you may credit. Every other value: do not release goods, do not increase the user's balance, do not ship.
slip_processing and under_review mean the money has moved but the check is not finished. They are not "failed" and they are not "success".
Three levels of status — only two of them drive money decisions
| Level | Read from | Values | Can it decide money? |
|---|---|---|---|
| Deposit | data.object.status on a deposit_order object | 9 | ✅ Only settled = credit the user. |
| Withdrawal | data.object.status on a withdrawal_order object, plus filled_minor / remaining_minor | 7 | ✅ Only settled and remaining_minor = 0 = close the order. |
| Leg / fill | legs[].leg_status (deposit side) · fills[] (withdrawal side) | 2 (pending | complete) | 🔴 No. Progress bars only. |
leg_status = "complete" does not mean money arrived
legs[] is "where the user has to transfer", not "the state of the money". One deposit can be split into two destinations (leg_count: 2); leg 1 can be complete while the deposit as a whole is nowhere near settled.
The only thing that means money arrived is status = "settled".
Every object that changes state carries a version (integer) that increases on every change, and two events for the same object can never share a version. Use it to stop an older event from overwriting a newer one — see Ordering is not guaranteed — the version guard.
Deposits
Diagram
stateDiagram-v2
direction TB
[*] --> awaiting_liquidity: POST /v1/pay/deposits answers 202 (no destination yet)
[*] --> awaiting_payment: POST /v1/pay/deposits answers 201 (matched immediately)
awaiting_liquidity --> awaiting_payment: destination found (deposit.matched)
awaiting_liquidity --> canceled: genuinely out of options (no_liquidity_available)
awaiting_payment --> slip_processing: consumer claims receipt after HTTP 202
awaiting_payment --> expired: past expires_at with no slip
awaiting_payment --> canceled: canceled (only while no slip exists)
slip_processing --> settled: exact passes all 6, or amount-only short/overpay passes the other 5
slip_processing --> under_review: a real non-amount, ambiguity, or policy reason needs a human
slip_processing --> awaiting_payment: image unreadable, time left
slip_processing --> expired: image unreadable, time is up
expired --> slip_processing: late slip within 24h
canceled --> slip_processing: late slip (always forced into the queue)
expired --> under_review: fallback route confirmed after the order closed
canceled --> under_review: fallback route confirmed after the order closed
under_review --> under_review: escalated on timeout (new version, money untouched)
under_review --> settled: reviewer approves
under_review --> rejected: reviewer does not accept this slip
settled --> reversed: bank recall / account freeze / dispute lost
rejected --> [*]
reversed --> [*]
note right of settled
the only point where you may credit
deposit.settled + 2 balance.credited
(deposit_settled and fee)
end note
note left of expired
expired is not a rejection of the money
the late-slip window closes at
late_slip_accepted_until
end note
note right of under_review
there is no "timed out, auto-approve" edge
and there never will be
end noteWhat each status means
status | Meaning | 💰 Where the money is | Actually final? |
|---|---|---|---|
awaiting_liquidity | Order created, but no destination has been found yet · no QR, no account number yet | Not transferred | No |
awaiting_payment | Destination matched; legs[] and expires_at are present | Not transferred (still with the user) | No |
slip_processing | Slip received, being checked against the bank | Transferred, awaiting the check | No |
under_review | Failed non-amount check / ambiguity / policy hold / another human decision · reasons in review.reasons[] · amount-only short-pay/overpay with the other 5 valid skips the queue | Transferred, awaiting the check | No |
settled | Credited; balance_transaction_id is present | In the system · actually withdrawable after clearing_available_at (T+72h) | Almost — the only exit is reversed |
expired | Time ran out with no slip · 🔴 not a rejection of the money | Usually not transferred · in the late-slip case, transferred | Check is_terminal |
canceled | Closed before transfer — merchant/user canceled, or the system ran out of destinations | Usually not transferred | Check is_terminal |
rejected | A reviewer decided not to accept this slip · reason in rejection.display_th | Not credited, no ledger entry | ✅ Final |
reversed | Was settled, then reversed | Debited back (balance.debited) | ✅ Final |
rejected does not mean the money was refunded
rejected means "this slip will not be used to credit this order". If real money already left the user's account, that goes through claims; it does not end at this status.
What you must do, and what to tell the end user
The Thai copy below is tested wording that measurably reduces support tickets. Use it as-is — your end users read Thai.
status | What you must do | Thai copy for the end user (English gloss) |
|---|---|---|
awaiting_liquidity | Waiting screen with a spinner · offer amounts from liquidity.suggested_amounts_minor[] · 🔴 no QR, no "transfer now", no failure message | "กำลังจัดหาช่องทางโอน กรุณารอสักครู่" (Finding a transfer route, please wait a moment.) |
awaiting_payment | Show every entry in legs[], a countdown from expires_at, and a "transfer only from the account you registered" box more prominent than the QR | "โอนเงิน 5,000 บาท จากบัญชีที่แจ้งไว้ ภายใน 08:24 น." (Transfer THB 5,000 from the account you registered, before 08:24.) |
slip_processing | Disable the re-upload button · 🔴 do not release goods | "กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที" (Checking the slip with the bank, under 1 minute.) |
under_review | Show review.reasons[0].display_th verbatim · yellow or blue, never red · 🔴 no "transfer again" button | Use the review.reasons[0].display_th we send. Every string has all three beats: the slip was received / what it is waiting on / the money is safe, do not transfer again. |
settled | Credit the end user with amount_received_minor · move your own wallet by credited_amount_minor | "เติมเงินสำเร็จ 5,000 บาท" (Top-up successful, THB 5,000.) |
settled + partial: true | Credit what actually arrived, then offer a new order for the difference | "ได้รับยอดที่โอนจริง 3,000 บาท จากรายการ 5,000 บาท" (Received the amount actually transferred, THB 3,000, of a THB 5,000 order.) |
expired (is_terminal: false) | 🔴 Keep the slip-upload button until late_slip_accepted_until. Do not close the order. | "หมดเวลาแล้ว ถ้าคุณโอนไปแล้ว อัปโหลดสลิปได้ภายใน 24 ชั่วโมง" (Time is up. If you already transferred, you can upload the slip within 24 hours.) |
canceled + late_slip_accepted_until present | 🔴 Identical to expired (the cancel-then-accidentally-transfer race) | "ยกเลิกรายการแล้ว ถ้าคุณเผลอโอนไปแล้ว อัปโหลดสลิปได้ถึง …" (Order canceled. If you transferred by mistake, you can still upload the slip until …) |
canceled + is_terminal: true | Safe to close · offer a "create a new order" button | "ยกเลิกรายการแล้ว" (Order canceled.) |
rejected | Show rejection.display_th plus a copyable deposit_id | "รายการไม่ผ่านการตรวจสอบ ติดต่อฝ่ายบริการพร้อมเลขที่รายการ" (This order did not pass verification. Contact support with the order number.) |
reversed | 🔴 Debit the user back and always notify — this event cannot be unsubscribed | "รายการถูกยกเลิกภายหลัง ยอดถูกปรับกลับ" (This order was reversed; the amount has been adjusted back.) |
overpayment{} present (status still settled) | Credit only amount_received_minor; show the excess, claim_id, and a contact-support action · do not promise a refund | "รายการสำเร็จตามยอดที่สร้าง แต่พบยอดโอนเกิน กรุณาติดต่อฝ่ายบริการพร้อมเลขเรื่อง" (The order succeeded at its ordered amount, but we found an excess transfer. Contact support with the claim number.) |
The two statuses merchants close too early
expired and canceled are not always the end. Check is_terminal, not the status name.
While is_terminal is false and late_slip_accepted_until has not passed, the user can still upload a slip after the fact — and you will get deposit.settled for an order you already marked failed. Your handler must be able to accept settled from any internal state of yours.
The under_review reasons
review.reasons[] is already ordered by importance — if your screen only has room for one line, use reasons[0].
The middle column below is the gist of each reason, so you can tell them apart at a glance. It is not the string to render: the exact wording ships in the payload as display_th / display_en.
code | Gist | Who can fix it (actionable_by) |
|---|---|---|
AMOUNT_MISMATCH | The amount differs and another review reason also exists; an amount-only mismatch never opens review | end_user |
TIME_WINDOW | The transfer time falls outside this order's window | support |
SENDER_BANK_MISMATCH | The sending bank is not the one registered | end_user |
SENDER_ACCOUNT_MISMATCH | The sending account is not the one registered | end_user |
SENDER_NAME_MISMATCH | The sender's account name does not match the one registered | end_user |
RECEIVER_MISMATCH | Transferred to the wrong destination account; contact support (🔴 no account details are given, and you must not guess them) | end_user |
BANK_DISABLED | The sending bank has temporarily stopped automated verification; staff will check manually | support |
DUPLICATE_SLIP | This slip has already been used | end_user |
SLIP_INVALID | The slip failed bank verification; staff are checking | support |
SLIP_UNREADABLE | The slip image cannot be read (arrives in deposit.slip_rejected, not under_review) | end_user |
AMBIGUOUS_MATCH | The system could not match the order unambiguously; staff are checking | support |
PROVIDER_UNAVAILABLE | Automated verification is temporarily unavailable; staff will check manually | support |
SENDER_ACCOUNT_UNAVAILABLE | Same as PROVIDER_UNAVAILABLE | support |
LATE_SLIP | The slip arrived after the order expired; staff are checking | support |
CANCELED_ORDER_SLIP | The slip arrived after the order was canceled; staff are checking | support |
ALLOCATION_CONFLICT | Orders overlap; staff are checking (a dispute is open) | support |
SETTLE_CONFLICT | Automated verification is temporarily unavailable; staff will check manually | support |
ROUTE_VERIFICATION_PENDING | The fallback route needs extra verification; staff are checking | support |
LATE_ROUTE_CONFIRMATION | The fallback route confirmed after the order closed; staff are checking | support |
NO_SLIP_IMAGE | A reference number was received; staff are checking with the bank | support |
RISK_HOLD | The order is under a safety review | support |
You do not write the copy yourself
Every reason ships with display_th and display_en inside the payload. ReviewReasonCode is an open enum — we can add codes without it counting as a breaking change, so your code must always have a default: that falls back to rendering display_th.
Withdrawals
Diagram
stateDiagram-v2
direction TB
[*] --> pending_approval: POST /v1/pay/withdrawals (approval required) · locks amount + fee
[*] --> open: POST /v1/pay/withdrawals (no approval needed) · locks amount + fee
pending_approval --> open: back office approves
pending_approval --> rejected: back office rejects · full refund
pending_approval --> canceled: canceled / 24h approval timeout · full refund
open --> open: a payer is matched (withdrawal.matched · status unchanged)
open --> partially_filled: first fill lands (withdrawal.filled)
partially_filled --> partially_filled: next fill lands (withdrawal.filled again)
open --> settled: a single fill covers the whole amount
partially_filled --> settled: filled_minor = amount_total_minor
open --> expired: past expires_at with remaining left
partially_filled --> expired: past expires_at with remaining left
open --> canceled: cancel just the remainder
partially_filled --> canceled: cancel just the remainder
settled --> settled: a fill is reversed (withdrawal.fill_reversed · status never goes back)
partially_filled --> partially_filled: a fill is reversed (status never goes back)
settled --> [*]
expired --> [*]
canceled --> [*]
rejected --> [*]
note right of partially_filled
one withdrawal takes many fills
withdrawal.filled arrives repeatedly
close only when settled AND remaining_minor = 0
end noteWhat each status means
status | Meaning | 💰 The withdrawer's money | Still eligible for matching? |
|---|---|---|---|
pending_approval | Created, waiting for back-office approval | amount_total_minor + fee_total_minor already deducted from available balance, 0 paid out | ❌ |
open | Approved, in the matching queue, no fill settled yet | Fully locked · may have reserved_minor > 0 | ✅ while before expires_at |
partially_filled | At least one fill has settled, not complete yet | filled_minor paid out · the rest still locked | ✅ while remaining_minor > 0 |
settled | filled_minor = amount_total_minor | Fully released | ❌ |
expired | Past expires_at with an unfilled remainder | refunded_minor returned, plus any unused fee | ❌ |
canceled | The remainder was canceled deliberately | Same as expired | ❌ |
rejected | Back office rejected it during pending_approval (the only way into this status) | Full refund | ❌ |
expired / canceled does not mean nothing was paid out
A THB 10,000 withdrawal that paid out 4,000 and then expired is status: "expired" with filled_minor: 400000.
⇒ Never refund the user the full amount just because you saw expired. Read refunded_minor, which is what was actually returned. Every payload carries amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor as separate fields.
What you must do, and what to tell the end user
status | What you must do | Thai copy for the end user (English gloss) |
|---|---|---|
pending_approval | Keep the cancel button available · 🔴 do not allow editing the amount or the destination account (cancel and create a new one) | "คำขอถอนอยู่ระหว่างอนุมัติ" (Withdrawal request is awaiting approval.) |
open | Show the locked total_locked_minor | "กำลังจัดหาผู้โอน" (Finding a payer.) |
partially_filled | 🔴 Render it as multi-fill progress, not a single status badge · read the numbers from filled_minor / remaining_minor | "ได้รับแล้ว 4,000 จาก 10,000 บาท กำลังรอส่วนที่เหลือ" (Received 4,000 of 10,000 THB, waiting for the rest.) |
settled | Close the order only if remaining_minor = 0 as well | "ถอนสำเร็จ 10,000 บาท" (Withdrawal complete, THB 10,000.) |
expired | Refund by refunded_minor, not amount_total_minor | "ส่วนที่เหลือ 6,000 บาท ถูกคืนเข้าบัญชีของคุณแล้ว" (The remaining THB 6,000 has been returned to your account.) |
canceled | Same as expired | "ยกเลิกรายการถอนแล้ว ยอดที่เหลือถูกคืนแล้ว" (Withdrawal canceled; the remaining amount has been refunded.) |
rejected | Show rejection.display_th · a new request can be created | "คำขอถอนไม่ได้รับอนุมัติ" (The withdrawal request was not approved.) |
dispute{} present (status unchanged) | Submit evidence before dispute.respond_by via POST /v1/pay/disputes/{id}/evidence | "เราได้รับแจ้งว่ายังไม่ได้รับเงิน กำลังตรวจสอบ" (We were told the money has not arrived; we are investigating.) |
The biggest difference from the market on the withdrawal side
Typical provider: 1 withdrawal = 1 callback at the end. Us: one withdrawal can produce withdrawal.filled 7 times, because the amount is filled by several different payers.
Code written as if (callback) closeWithdrawal() closes the order on the first fill. The correct close condition is status === 'settled' && remaining_minor === 0. Details at One withdrawal, many fills.
Dimensions that are not status but change the answer
Status does not tell you everything. These fields run parallel to status and change what the UI has to do.
| Field | Where | Why it matters |
|---|---|---|
is_terminal | deposit_order | An expired/canceled order with is_terminal: false can still take a slip for 24 more hours. |
late_slip_accepted_until | deposit_order (may be null) | The single clock for the late-slip window · null = there never was a destination to transfer to, so you may close it. |
partial | deposit.settled | true = short-pay accepted at actual value (A=V<X), with settled_via:'auto' when all five non-amount checks pass and 'review' only after another review reason is resolved; overpayment is false with overpayment{}. |
settled_via | deposit.settled | auto | review — for reporting, not for decisions. |
clearing_available_at | deposit.settled | When this amount moves from pending_clearing to available (T+72h) ⇒ before this time, creating a withdrawal returns 402. |
review.escalated | deposit_order.review | Escalated after a timeout · version moves, but status and money do not. |
review.estimated_resolution_bucket | deposit_order.review | under_15m | under_1h | under_4h | extended — safe to show the user. |
overpayment{} | deposit_order | A same-transfer excess or an additional transfer · 🔴 status stays settled; presence is the flag, while Claim is the latest support state. |
dispute{} | withdrawal_order | The withdrawer says the money never arrived · status unchanged · funds_on_hold: true. |
filled_minor / remaining_minor | withdrawal_order | The single source of truth for how much has been filled — 🔴 never compute it yourself by summing the events you received. |
So how do you write the code
It reduces to three lines.
- Read status from
data.object.status, not fromevent.type—event.typeonly tells you what happened. - Persist it with one UPDATE carrying a version guard, not SELECT-then-if-then-UPDATE.
- Hang side effects off the result of the UPDATE (
rowCount), not off the event type.
The UPDATE below assumes you inserted both the deposit row and pg_deposit_correlations from the create response (201/202). If that precondition is not guaranteed, use the atomic UPSERT from Event ordering; never mistake rowCount=0 on a missing row for a stale event.
-- version guard: a late-arriving old event cannot overwrite newer state
-- (rowCount = 0 ⇒ answer 200 and do nothing)
UPDATE deposits
SET status = $2,
version = $3,
is_terminal = $4,
late_slip_accepted_until = $5,
ordered_amount_minor = $6,
transferred_amount_minor = $7,
amount_received_minor = $8,
fee_total_minor = $9,
credited_amount_minor = $10,
overpayment = $11,
updated_at = now()
WHERE deposit_id = $1
AND version < $3
RETURNING status, amount_received_minor, credited_user_at;// Run inside the inbox worker: version + DB money + outbox + processed_at commit together.
await db.tx(async (t) => {
const { rows: [correlation] } = await t.query(
`SELECT our_order_id, our_user_id
FROM pg_deposit_correlations
WHERE pg_deposit_id = $1`, [obj.id]);
if (!correlation) throw new Error('missing deposit correlation; retry webhook');
const { rowCount, rows } = await t.query(UPDATE_SQL, [
obj.id, obj.status, obj.version, obj.is_terminal, obj.late_slip_accepted_until ?? null,
obj.ordered_amount_minor ?? null, obj.transferred_amount_minor ?? null,
obj.amount_received_minor ?? null, obj.fee_total_minor ?? null,
obj.credited_amount_minor ?? null, JSON.stringify(obj.overpayment ?? null)
]);
// Run even for an older snapshot version: every D17 transfer has a distinct claim_id.
if (obj.overpayment) await db.appendOrValidateOverpaymentFlag(t, obj)
if (rowCount === 0) {
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id]);
return; // stale event — safe to drop
}
if (obj.status === 'settled' && rows[0].credited_user_at === null) {
const credited = await t.query(
`UPDATE deposits SET credited_user_at = now()
WHERE deposit_id = $1 AND status = 'settled'
AND credited_user_at IS NULL`, [obj.id]);
if (credited.rowCount === 1) {
await t.query(`UPDATE users SET balance_minor = balance_minor + $1 WHERE id = $2`,
[rows[0].amount_received_minor, correlation.our_user_id]);
await enqueueEffect(t, `deposit:${obj.id}`, 'release_goods', {
order_id: correlation.our_order_id });
}
}
if (obj.status === 'reversed') {
const reversed = await t.query(
`UPDATE deposits SET reversed_at = now()
WHERE deposit_id = $1 AND status = 'reversed'
AND credited_user_at IS NOT NULL AND reversed_at IS NULL
RETURNING amount_received_minor`, [obj.id]);
if (reversed.rowCount === 1) {
await t.query(`UPDATE users SET balance_minor = balance_minor - $1 WHERE id = $2`,
[reversed.rows[0].amount_received_minor, correlation.our_user_id]);
await enqueueEffect(t, `deposit:${obj.id}`, 'hold_goods', {
order_id: correlation.our_order_id });
}
}
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id]);
});Persist pg_deposit_correlations from the create request + response before processing webhooks, as shown in webhook ordering. metadata is optional and may be absent altogether. If the correlation has not arrived yet, roll back and return non-2xx for a retry—never commit the version and look for the user afterwards.
appendOrValidateOverpaymentFlag must execute the pg_deposit_overpayment_flags insert-or-validate from the ordering page, always before the stale-version return. The deposit row's overpayment value is only the latest projection; it cannot replace the history of multiple claims.
enqueueEffect is the transactional-outbox helper from that page. Never call an external service inside the database transaction. One-shot work uses a resource scope (deposit:${obj.id}) and is enqueued only when the business guard returns rowCount = 1.
Four X/V/A/E amounts with different meanings
ordered_amount_minor (X) = what the order was created for.
transferred_amount_minor (V) = what the bank authoritatively verified as transferred.
amount_received_minor (A) = what the gateway accepted into this order → this is what you credit the end user with; on overpayment it equals X.
overpayment.amount_minor (E) = the excess in an open support claim. Same-slip overpayment must satisfy V = A + E; never auto-credit or auto-refund it.
credited_amount_minor = the amount after fees → this is what moves your merchant wallet.
amount_received_minor − fee_total_minor = credited_amount_minor, on every order, always. Swap them and either every user is short by exactly the fee, or your books run high all month.
Transition → event map
Use this to check your handlers cover everything.
Deposits
────────
(create) → awaiting_liquidity ⇒ deposit.created + deposit.awaiting_liquidity
(create) → awaiting_payment ⇒ deposit.created + deposit.matched
awaiting_liquidity → awaiting_payment ⇒ deposit.matched
awaiting_liquidity → canceled ⇒ deposit.canceled (no_liquidity_available)
awaiting_payment → slip_processing ⇒ deposit.slip_received
awaiting_payment → expired ⇒ deposit.expired (is_terminal=false)
awaiting_payment → canceled ⇒ deposit.canceled (is_terminal=false)
slip_processing → awaiting_payment ⇒ deposit.slip_rejected (retryable=true)
slip_processing → expired ⇒ deposit.slip_rejected + deposit.expired
slip_processing → settled ⇒ deposit.settled + balance.credited ×2
slip_processing → under_review ⇒ deposit.under_review
expired | canceled → slip_processing ⇒ deposit.slip_received (is_late=true)
expired | canceled → under_review ⇒ deposit.under_review (LATE_ROUTE_CONFIRMATION)
under_review [escalate] under_review ⇒ deposit.under_review (escalated=true, new version)
under_review → settled ⇒ deposit.settled + balance.credited ×2
under_review → rejected ⇒ deposit.rejected
[a leg moves] (status unchanged) ⇒ deposit.leg_updated
settled [T+72h] (status unchanged) ⇒ balance.cleared
settled → reversed ⇒ deposit.reversed + balance.debited
settled [a second slip] (status unchanged) ⇒ deposit.overpaid
expired | canceled [window closes] (status unchanged) ⇒ deposit.expired | deposit.canceled (is_terminal=true)
🔴 There is no "timed out, auto-approve" edge, and there never will be.
🔴 There is no awaiting_liquidity → expired edge (an order that never had a destination is always canceled).
Withdrawals
───────────
(create) → pending_approval | open ⇒ withdrawal.created + balance.debited
pending_approval → open ⇒ (no dedicated event — read data.object.status)
pending_approval → rejected ⇒ withdrawal.rejected + balance.credited
pending_approval → canceled ⇒ withdrawal.canceled + balance.credited
open [on every match] open ⇒ withdrawal.matched
open → partially_filled ⇒ withdrawal.filled + balance.debited
partially_filled → partially_filled ⇒ withdrawal.filled + balance.debited
open | partially_filled → settled ⇒ withdrawal.filled + withdrawal.settled
open | partially_filled → expired ⇒ withdrawal.expired + balance.credited
open | partially_filled → canceled ⇒ withdrawal.canceled + balance.credited
[a fill is reversed] (status never goes back) ⇒ withdrawal.fill_reversed + balance.credited
[withdrawer confirms receipt] (status unchanged) ⇒ withdrawal.receipt_confirmed
[a dispute opens] (status unchanged) ⇒ withdrawal.disputed → withdrawal.dispute_resolvedThe 5 events you cannot unsubscribe from
deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled
The first three take money out of your wallet. Every endpoint is required to accept all five; you cannot turn them off, so you need handlers on day one.
Next
| If you are… | Go to |
|---|---|
| Looking for every deposit status in detail with sample payloads | The 9 deposit statuses |
Handling repeated withdrawal.filled correctly | One withdrawal, many fills |
| Seeing statuses go backwards or wrong withdrawal totals | Ordering is not guaranteed — the version guard |
| Wondering what a field means and whether it is always present | Field dictionary |
| Getting an HTTP error and unsure whether to retry | Every error code |
| About to go live | Go-live checklist |
