Handling partial fills
This is where code ported from another gateway breaks most often.
One withdrawal is not one webhook
A THB 1,000 withdrawal filled with 300 + 400 + 300 gives you:
withdrawal.filled ← chunk 1 THB 300
withdrawal.filled ← chunk 2 THB 400
withdrawal.filled ← chunk 3 THB 300
withdrawal.settled ← summary THB 1,000 completeFour events, not one. Count withdrawal.matched — which fires every time a payer is reserved — and this single order produces 8 webhooks (created 1 + matched 3 + filled 3 + settled 1).
Code shaped like if (webhook) closeWithdrawal() closes the order on the first chunk, so the user is told "withdrawal complete, 1,000" when only 300 has arrived.
Why it works this way
We do not pay out of a house account. Different payers transfer directly into the withdrawing user's destination account, one at a time.
Payer A transfers 300 → your user's destination account (1 slip)
Payer B transfers 400 → your user's destination account (1 slip)
Payer C transfers 300 → your user's destination account (1 slip)Open the end user's bank statement and you see three lines, at three different times, from three different senders. There is no way to collapse them into one line, because they were three real transfers.
So the webhooks have to arrive per chunk to match what the ledger says. If we sent a single event at the end, you could never reconcile against the statement — and the user who watched 300 land would call to ask where the rest went.
Slip verification happens after the money is already in the account
On the payer's side, POST /v1/pay/deposits/{id}/slip answers 202 when durable intake succeeds. The slip is accepted first and checked afterwards, with no immediate 409 duplicate_slip; unconfirmed storage/queue admission returns a retryable 503.
What that means on your withdrawal side:
withdrawal.filledarrives slightly after the money actually lands in the bank account, because the verification pipeline has to finish. Your user may see the bank's SMS before your app changes status. Write the UI copy to survive that.- A chunk that was already
filledcan still be reversed if the after-the-fact check finds that slip unusable. You need a handler forwithdrawal.fill_reversed(🔴 that event cannot be unsubscribed).
The real timeline of one order
Order wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC · withdraw THB 1,000.00 (100000) · fee THB 26.75 (2675) · 102675 locked.
| Time (UTC) | event | version | filled_minor | remaining_minor | status |
|---|---|---|---|---|---|
| 03:02:11.508 | withdrawal.created | 1 | 0 | 100000 | open |
| 03:14:26.118 | withdrawal.matched (300 reserved) | 2 | 0 | 70000 | open |
| 03:19:47.985 | withdrawal.filled #1 | 3 | 30000 | 70000 | partially_filled |
| 03:21:05.220 | withdrawal.matched (400 reserved) | 4 | 30000 | 30000 | partially_filled |
| 03:33:12.741 | withdrawal.filled #2 | 5 | 70000 | 30000 | partially_filled |
| 03:40:58.771 | withdrawal.matched (300 reserved) | 6 | 70000 | 0 | partially_filled |
| 03:52:40.096 | withdrawal.filled #3 | 7 | 100000 | 0 | settled |
| 03:52:40.102 | withdrawal.settled | 8 | 100000 | 0 | settled |
Two things here are counter-intuitive:
remaining_minordoes not drop onfilled; it drops onmatched.remainingmeans "the part nobody has committed to yet". When a payer is reserved, that amount moves to "reserved" immediately. When the transfer actually lands, it moves from "reserved" intofilled_minor— andremainingdoes not move at all.statusbecomessettledon the thirdfilled, not on the summary event.withdrawal.settledarrives afterwards with a higherversion. Do not wait for the summary event before readingstatus.
All four payloads
1 — withdrawal.filled, chunk 1 (THB 300)
{
"id": "evt_01K3PBR3N5W9ZS2QT6HY0KDXVC",
"object": "event",
"type": "withdrawal.filled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T03:19:47.985Z",
"sequence": 91018,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 3,
"status": "partially_filled",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 30000,
"remaining_minor": 70000,
"fee_recognized_minor": 802,
"settled_splits": 1,
"max_splits": 4,
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
},
"fill": {
"leg_ref": "leg_4TQ2XM9K",
"sequence": 1,
"amount_minor": 30000,
"fee_recognized_minor": 802,
"settled_at": "2026-08-21T03:19:47.903Z",
"balance_transaction_id": "btxn_01K3PBR2M4V8ZT1QW5HN0YDXR7",
"auto_confirm_at": "2026-08-21T06:19:47.903Z"
},
"previous_attributes": {
"version": 2,
"status": "open",
"filled_minor": 0,
"fee_recognized_minor": 0,
"settled_splits": 0
}
}
}2 — withdrawal.filled, chunk 2 (THB 400)
{
"id": "evt_01K3PBS8T2X4ZM6QW9HR0NDVKC",
"object": "event",
"type": "withdrawal.filled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T03:33:12.741Z",
"sequence": 91033,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 5,
"status": "partially_filled",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 70000,
"remaining_minor": 30000,
"fee_recognized_minor": 1872,
"settled_splits": 2,
"max_splits": 4,
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
},
"fill": {
"leg_ref": "leg_7HB3ND5R",
"sequence": 2,
"amount_minor": 40000,
"fee_recognized_minor": 1070,
"settled_at": "2026-08-21T03:33:12.664Z",
"balance_transaction_id": "btxn_01K3PBS7R1W3YQ0MT5KN8ZDXV6",
"auto_confirm_at": "2026-08-21T06:33:12.664Z"
},
"previous_attributes": {
"version": 4,
"filled_minor": 30000,
"remaining_minor": 70000,
"fee_recognized_minor": 802,
"settled_splits": 1
}
}
}3 — withdrawal.filled, chunk 3 (THB 300) — the one that completes the order
{
"id": "evt_01K3PBT5V9Z2QN7WM4HD0RXKYC",
"object": "event",
"type": "withdrawal.filled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T03:52:40.096Z",
"sequence": 91050,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 7,
"status": "settled",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 100000,
"remaining_minor": 0,
"fee_recognized_minor": 2675,
"settled_splits": 3,
"max_splits": 4,
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
},
"fill": {
"leg_ref": "leg_2WC8FJ6P",
"sequence": 3,
"amount_minor": 30000,
"fee_recognized_minor": 803,
"settled_at": "2026-08-21T03:52:40.019Z",
"balance_transaction_id": "btxn_01K3PBT4U8Y1ZR6QN3KM0WDXV5",
"auto_confirm_at": "2026-08-21T06:52:40.019Z"
},
"previous_attributes": {
"version": 6,
"status": "partially_filled",
"filled_minor": 70000,
"remaining_minor": 30000,
"fee_recognized_minor": 1872,
"settled_splits": 2
}
}
}4 — withdrawal.settled, the summary
{
"id": "evt_01K3PBT6X1R3SM8QW5HN0ZDKVC",
"object": "event",
"type": "withdrawal.settled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T03:52:40.102Z",
"sequence": 91051,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 8,
"status": "settled",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 100000,
"remaining_minor": 0,
"refunded_minor": 0,
"fee_recognized_minor": 2675,
"settled_splits": 3,
"max_splits": 4,
"dust_absorbed": false,
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"fills": [
{ "leg_ref": "leg_4TQ2XM9K", "amount_minor": 30000, "fee_recognized_minor": 802,
"settled_at": "2026-08-21T03:19:47.903Z", "receipt_state": "confirmed" },
{ "leg_ref": "leg_7HB3ND5R", "amount_minor": 40000, "fee_recognized_minor": 1070,
"settled_at": "2026-08-21T03:33:12.664Z", "receipt_state": "awaiting_confirm" },
{ "leg_ref": "leg_2WC8FJ6P", "amount_minor": 30000, "fee_recognized_minor": 803,
"settled_at": "2026-08-21T03:52:40.019Z", "receipt_state": "awaiting_confirm" }
],
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
},
"previous_attributes": { "version": 7 }
}
}withdrawal.matched — the event that is not money
It fires every time the system reserves a payer for this order, not only the first time. The fields that set it apart from the others are matched_legs and reserved_minor.
{
"type": "withdrawal.matched",
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 2,
"status": "open",
"filled_minor": 0,
"remaining_minor": 70000,
"reserved_minor": 30000,
"matched_legs": 1
},
"previous_attributes": { "version": 1, "remaining_minor": 100000, "reserved_minor": 0 }
}
}🔴 The withdrawal.matched payload carries no id of the deposit on the other side, and never will. You see your own chunk through leg_ref only — and the leg_ref the payer's side receives is a different value from yours.
What matched is good for
Telling the user "someone is transferring 300 to you right now", which cuts support calls. Never add it to a total and never close an order on it. The payer may not transfer at all, and that chunk is then released back into the pool silently, with no event to announce it (remaining_minor simply goes back up in the next event).
Why the chunks carry different fees
| chunk | amount | chunk fee_recognized_minor | running total on the order |
|---|---|---|---|
| #1 | 30000 | 802 | 802 |
| #2 | 40000 | 1070 | 1872 |
| #3 | 30000 | 803 | 2675 |
| 100000 | total 2675 | = fee_total_minor exactly |
Chunk 1 and chunk 3 are the same size (THB 300) but their fees differ by one satang, because the system computes a running total and takes the difference rather than pricing each chunk on its own:
running fee after this chunk = floor( fee_total_minor × filled_minor after this chunk ÷ amount_total_minor )
fee for this chunk = running fee after this chunk − running fee before itRounding losses therefore do not vanish — they accumulate onto the final chunk, so the sum always lands exactly on fee_total_minor once the order is complete.
Do not compute per-chunk fees yourself
Write chunk_fee = fee_total × chunk_amount / total_amount and you get 802 + 1070 + 802 = 2674 — one satang short on every order, and a month-end reconciliation that does not balance.
Use the fee_recognized_minor we send you, exactly as sent. And remember a fixed fee (fixed_fee) belongs to the order, not the chunk — four chunks do not cost four fixed fees.
What to store, and how
The core of it is knowing what overwrites and what appends.
| Data | Store in | Correct statement | Why |
|---|---|---|---|
Order-level totals (status, filled_minor, remaining_minor, settled_splits, fee_recognized_minor) | pg_withdrawals (one row per order) | UPDATE ... WHERE pg_version < $new | 🔴 overwrite only when version is higher than what you hold — webhook order is not guaranteed |
| Each individual chunk | pg_withdrawal_fills (PK = leg_ref) | INSERT ... ON CONFLICT (leg_ref) DO NOTHING | 🔴 append, never overwrite · a redelivery must not add a second row |
| Wallet movements | pg_balance_txns (PK = btxn_id) | INSERT ... ON CONFLICT (btxn_id) DO NOTHING | a balance_transaction is immutable and has no version, so dedupe by id |
| Events already processed | pg_webhook_inbox (PK = event_id) | inbox + outbox commit in one transaction | replay protection · keep at least 31 days |
CREATE TABLE pg_withdrawals (
pg_withdrawal_id text PRIMARY KEY,
pg_version bigint NOT NULL,
status text NOT NULL,
amount_total_minor bigint NOT NULL,
fee_total_minor bigint NOT NULL,
total_locked_minor bigint NOT NULL,
filled_minor bigint NOT NULL DEFAULT 0,
remaining_minor bigint NOT NULL,
refunded_minor bigint NOT NULL DEFAULT 0,
fee_recognized_minor bigint NOT NULL DEFAULT 0,
settled_splits smallint NOT NULL DEFAULT 0,
max_splits smallint,
our_ref text NOT NULL,
closed_at timestamptz, -- non-null = closed on our side; blocks double-closing
replacement_withdrawal_id text, -- replacement opened after W14, when present
updated_at timestamptz NOT NULL DEFAULT now()
);
-- This table exists for the timeline, the audit trail and statement reconciliation only.
-- 🔴 Never drive business logic from SUM(amount_minor) over this table.
CREATE TABLE pg_withdrawal_fills (
leg_ref text PRIMARY KEY,
pg_withdrawal_id text NOT NULL REFERENCES pg_withdrawals,
sequence smallint,
amount_minor bigint NOT NULL,
fee_recognized_minor bigint NOT NULL,
settled_at timestamptz NOT NULL,
balance_transaction_id text NOT NULL,
auto_confirm_at timestamptz,
reversed_at timestamptz -- set when withdrawal.fill_reversed arrives
);
-- A separate reversal tombstone accepts reversal-before-fill without inventing a fill row.
CREATE TABLE pg_withdrawal_fill_reversals (
leg_ref text PRIMARY KEY,
pg_withdrawal_id text NOT NULL REFERENCES pg_withdrawals,
reason_code text NOT NULL,
reverses_balance_transaction_id text NOT NULL UNIQUE,
reversal_balance_transaction_id text NOT NULL UNIQUE,
reversed_at timestamptz NOT NULL,
replacement_withdrawal_id text,
effect_enqueued_at timestamptz
);The correct handler (Node)
export async function applyWithdrawalEvent(event) {
const o = event.data.object
// Inbox + version guard + tombstone + outbox + processed_at must commit together.
await db.tx(async (t) => {
// The receiver verifies the signature before this call; event_id blocks delivery replay.
const inbox = await t.query(
`INSERT INTO pg_webhook_inbox (event_id, received_at)
VALUES ($1, now()) ON CONFLICT (event_id) DO NOTHING`, [event.id])
if (inbox.rowCount === 0) return
// 1) Order totals — the highest-version snapshot always wins.
const aggregate = await t.query(
`INSERT INTO pg_withdrawals (pg_withdrawal_id, pg_version, status,
amount_total_minor, fee_total_minor, total_locked_minor,
filled_minor, remaining_minor, refunded_minor, fee_recognized_minor,
settled_splits, max_splits, our_ref)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (pg_withdrawal_id) DO UPDATE
SET pg_version = EXCLUDED.pg_version,
status = EXCLUDED.status,
filled_minor = EXCLUDED.filled_minor,
remaining_minor = EXCLUDED.remaining_minor,
refunded_minor = EXCLUDED.refunded_minor,
fee_recognized_minor = EXCLUDED.fee_recognized_minor,
settled_splits = EXCLUDED.settled_splits,
updated_at = now()
WHERE pg_withdrawals.pg_version < EXCLUDED.pg_version`,
[o.id, o.version, o.status, o.amount_total_minor, o.fee_total_minor, o.total_locked_minor,
o.filled_minor, o.remaining_minor, o.refunded_minor ?? 0, o.fee_recognized_minor ?? 0,
o.settled_splits ?? 0, o.max_splits ?? null, o.metadata.ticket]
)
const f = event.data.fill // sibling of data.object in the OpenAPI contract
// 2) Fills append. If a reversal arrived first, sync reversed_at from its tombstone.
if (event.type === 'withdrawal.filled') {
await t.query(
`INSERT INTO pg_withdrawal_fills
(leg_ref, pg_withdrawal_id, sequence, amount_minor, fee_recognized_minor,
settled_at, balance_transaction_id, auto_confirm_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (leg_ref) DO NOTHING`,
[f.leg_ref, o.id, f.sequence ?? null, f.amount_minor, f.fee_recognized_minor,
f.settled_at, f.balance_transaction_id, f.auto_confirm_at ?? null]
)
await t.query(
`UPDATE pg_withdrawal_fills f SET reversed_at = r.reversed_at
FROM pg_withdrawal_fill_reversals r
WHERE f.leg_ref = $1 AND r.leg_ref = f.leg_ref
AND r.pg_withdrawal_id = f.pg_withdrawal_id AND f.reversed_at IS NULL`, [f.leg_ref])
}
// 3) A reversal is a financial delta independent of the aggregate version guard.
// A stale event still has to compensate exactly once.
if (event.type === 'withdrawal.fill_reversed') {
const r = event.data.reversal
const allowed = new Set(['BANK_RECALL', 'ACCOUNT_FROZEN', 'SLIP_INVALIDATED_AFTER_SETTLEMENT'])
if (!allowed.has(r.reason_code)) throw new Error('invalid withdrawal reversal reason')
if (r.reverses_balance_transaction_id !== f.balance_transaction_id)
throw new Error('reversal does not point to the fill balance transaction')
await t.query(
`INSERT INTO pg_withdrawal_fill_reversals
(leg_ref, pg_withdrawal_id, reason_code, reverses_balance_transaction_id,
reversal_balance_transaction_id, reversed_at, replacement_withdrawal_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (leg_ref) DO NOTHING`,
[f.leg_ref, o.id, r.reason_code, r.reverses_balance_transaction_id,
r.balance_transaction_id, event.created_at, event.data.replacement_withdrawal_id ?? null]
)
// ON CONFLICT may mean a valid replay or a colliding payload; distinguish them.
const { rows: [stored] } = await t.query(
`SELECT * FROM pg_withdrawal_fill_reversals WHERE leg_ref = $1 FOR UPDATE`, [f.leg_ref])
if (!stored || stored.pg_withdrawal_id !== o.id || stored.reason_code !== r.reason_code ||
stored.reverses_balance_transaction_id !== r.reverses_balance_transaction_id ||
stored.reversal_balance_transaction_id !== r.balance_transaction_id ||
stored.replacement_withdrawal_id !== (event.data.replacement_withdrawal_id ?? null))
throw new Error('conflicting withdrawal fill reversal')
await t.query(
`UPDATE pg_withdrawal_fills SET reversed_at = COALESCE(reversed_at, $2)
WHERE leg_ref = $1 AND pg_withdrawal_id = $3`, [f.leg_ref, event.created_at, o.id])
await t.query(
`UPDATE pg_withdrawals
SET closed_at = NULL,
replacement_withdrawal_id = COALESCE($2, replacement_withdrawal_id),
updated_at = now()
WHERE pg_withdrawal_id = $1`, [o.id, event.data.replacement_withdrawal_id ?? null])
const effect = await t.query(
`UPDATE pg_withdrawal_fill_reversals SET effect_enqueued_at = now()
WHERE leg_ref = $1 AND effect_enqueued_at IS NULL
RETURNING leg_ref`, [f.leg_ref])
if (effect.rowCount === 1) await enqueueEffect(
t, `withdrawal-fill:${f.leg_ref}:reverse`, 'compensate_reversed_fill', {
withdrawal_id: o.id,
leg_ref: f.leg_ref,
amount_minor: f.amount_minor,
reason_code: r.reason_code,
reverses_balance_transaction_id: r.reverses_balance_transaction_id,
reversal_balance_transaction_id: r.balance_transaction_id,
replacement_withdrawal_id: event.data.replacement_withdrawal_id ?? null
})
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id])
return
}
if (aggregate.rowCount === 0) {
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id])
return // stale aggregate; a previously unseen fill/tombstone was still persisted
}
// 4) Status side effects depend on the version-guarded aggregate update.
switch (o.status) {
case 'partially_filled':
await enqueueEffect(t, event.id, 'show_progress', {
ticket: o.metadata.ticket, filled_minor: o.filled_minor, amount_total_minor: o.amount_total_minor })
break
case 'settled':
if (o.remaining_minor === 0) {
const closed = await t.query(
`UPDATE pg_withdrawals SET closed_at = now()
WHERE pg_withdrawal_id = $1 AND status = 'settled'
AND remaining_minor = 0 AND closed_at IS NULL`, [o.id])
if (closed.rowCount === 1) await enqueueEffect(
t, `withdrawal:${o.id}`, 'close_withdrawal', {
withdrawal_id: o.id, ticket: o.metadata.ticket })
}
break
case 'expired':
case 'canceled': {
const refundQueued = await t.query(
`UPDATE pg_withdrawals SET refund_enqueued_at = now()
WHERE pg_withdrawal_id = $1 AND status IN ('expired', 'canceled')
AND refund_enqueued_at IS NULL`, [o.id])
if (refundQueued.rowCount === 1) await enqueueEffect(
t, `withdrawal:${o.id}`, 'refund_unfilled', {
withdrawal_id: o.id, ticket: o.metadata.ticket, refunded_minor: o.refunded_minor })
break
}
case 'rejected': {
const lockReleased = await t.query(
`UPDATE pg_withdrawals SET user_lock_released_at = now()
WHERE pg_withdrawal_id = $1 AND status = 'rejected'
AND user_lock_released_at IS NULL`, [o.id])
if (lockReleased.rowCount === 1) await enqueueEffect(
t, `withdrawal:${o.id}`, 'release_user_lock', {
withdrawal_id: o.id, ticket: o.metadata.ticket })
break
}
default:
await enqueueEffect(t, event.id, 'update_local_status', {
ticket: o.metadata.ticket, status: o.status })
}
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id])
})
}enqueueEffect only inserts into the transactional outbox; never call the network inside the transaction. If compensation changes a balance in your own database, execute that SQL in the same transaction. pg_withdrawal_fill_reversals.effect_enqueued_at plus the stable key withdrawal-fill:{leg_ref}:reverse makes replay, reversal-before-fill, and out-of-order delivery compensate once. For W14 the handler reopens a locally closed order with closed_at = NULL and stores replacement_withdrawal_id; the old API object still has status: settled and must never be rewritten backwards.
Create one-shot transition markers and bind them to columns, not to "have I seen this event before":
ALTER TABLE pg_withdrawals
ADD COLUMN IF NOT EXISTS refund_enqueued_at timestamptz,
ADD COLUMN IF NOT EXISTS user_lock_released_at timestamptz;
-- closed_at, refund_enqueued_at, and user_lock_released_at commit with the outbox row.Three ways to get the numbers wrong
DANGER
🔴 Never drive logic from SUM(pg_withdrawal_fills.amount_minor) — events may still be in flight (retries run for up to ~92 hours). You will read a total that is too low and let the user withdraw the same money twice.
🔴 Never do filled_minor += fill.amount_minor — redelivery is normal, and adding on top of it inflates the total.
🔴 Never read the balance from balance_after on a balance_transaction — it is a snapshot taken when that transaction happened. If events arrive out of order, overwriting gives you a stale number. Use it to cross-check; take the real balance from GET /v1/pay/balance.
There is exactly one source of truth: data.object.filled_minor and data.object.remaining_minor, after they pass the version guard. The fills table is for the timeline and for statement reconciliation only.
When events arrive out of order
Actual arrival: filled #2 (v5) → filled #1 (v3) → settled (v8)
v5 first → pg_version 0 < 5 → writes filled_minor = 70000 ✓
v3 next → pg_version 5 < 3 is false → rowCount = 0 → stale totals do not overwrite ✓
but the leg_4TQ2XM9K row is still INSERTed into the fills table ✓ (timeline intact)
v8 next → 5 < 8 → writes filled_minor = 100000, status = settled → order closed ✓Answer 200 in every case, including when rowCount = 0. A non-2xx response makes us retry the same event 11 more times for nothing.
The right UI
❌ one status bar "Processing..."
✅ a multi-line timeline Received 700.00 of 1,000.00 THB
├ 03:19 +300.00 THB
├ 03:33 +400.00 THB
└ waiting for the remaining 300.00 THBThe end user is Thai, so ship the Thai strings below; the English in parentheses is for you, not for the screen.
status | message to show |
|---|---|
pending_approval | คำขอถอนอยู่ระหว่างอนุมัติ (Your withdrawal request is awaiting approval) |
open | กำลังจัดหาผู้โอน (Finding someone to transfer to you) |
partially_filled | ได้รับแล้ว 700 จาก 1,000 บาท กำลังรอส่วนที่เหลือ (700 of 1,000 THB received, waiting for the rest) |
settled | ถอนสำเร็จ 1,000 บาท (Withdrawal complete, 1,000 THB) |
expired | ส่วนที่เหลือ 300 บาท ถูกคืนเข้าบัญชีของคุณแล้ว (The remaining 300 THB has been returned to your account) |
canceled | ยกเลิกรายการถอนแล้ว ยอดที่เหลือถูกคืนแล้ว (Withdrawal canceled, the remaining balance has been returned) |
rejected | คำขอถอนไม่ได้รับอนุมัติ (The withdrawal request was not approved) |
🔴 leg_ref is an internal mechanism. Use it to order chunks as "1 / 2 / 3" if you like, but never surface it as a money status message.
The important case — expired half filled
The same THB 1,000 order got 300 + 400 = 700, then expires_at passed with nobody taking the last chunk.
{
"id": "evt_01K3PC5J2P8R4TN6QW0HZDXVKM",
"object": "event",
"type": "withdrawal.expired",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T09:02:14.338Z",
"sequence": 91788,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"data": {
"object": {
"id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
"object": "withdrawal_order",
"version": 9,
"status": "expired",
"merchant_reference": "WD-20260821-0007",
"end_user_ref": "u_88213",
"currency": "THB",
"amount_total_minor": 100000,
"fee_total_minor": 2675,
"total_locked_minor": 102675,
"filled_minor": 70000,
"remaining_minor": 30000,
"refunded_minor": 30000,
"fee_recognized_minor": 1872,
"settled_splits": 2,
"max_splits": 4,
"released_balance_transaction_id": "btxn_01K3PC5H1N7Q3SM5PV9KY0WDXR",
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
},
"previous_attributes": { "version": 8, "status": "partially_filled", "refunded_minor": 0 }
}
}Where the money went, and how much comes back
| Piece | Amount | Where it is now | Recoverable? |
|---|---|---|---|
| Successfully filled | 700.00 (70000) | already in the end user's bank account — payers really did transfer it | ❌ done; it was a real transfer between personal accounts |
| Principal never filled | 300.00 (30000) | returned to your available_minor | ✅ automatic |
| Fee actually used | 18.72 (1872) | kept, in proportion to the 70% that was paid out | ❌ |
| Fee never used | 8.03 (2675 − 1872) | returned to your available_minor | ✅ automatic |
Total back in your wallet = 30000 + 803 = 30803, out of the 102675 that was locked.
The amount to return to the user is not refunded_minor alone
refunded_minor = 30000 is the principal that was never filled. The unused portion of the fee (803) comes back as a separate movement.
You locked 102675 against the user when the order was created, so now you release 30803 back to them and keep 71872 debited (the 70000 they received plus the 1872 of fee that was used).
🔴 Reconcile the money that actually returned to your wallet from balance.credited / GET /v1/pay/balance/transactions, not from a single field on this event.
expired does not mean nothing was paid
The trap everyone falls into: status = 'expired' or 'canceled' with filled_minor > 0 — part of it was already paid out. That is why every payload carries amount_total_minor, filled_minor, refunded_minor and fee_recognized_minor as separate fields. Never read status alone.
Remainders too small to find a payer for
If the leftover is so small that nobody will take it, the system may close the order in full and absorb the difference itself. You get a normal withdrawal.settled with "dust_absorbed": true and filled_minor = amount_total_minor. Nothing special to do — the user received the full amount.
Next
| If you are about to | Go to |
|---|---|
| Get the version guard right the first time | Ordering and the version guard |
| Verify the signature before touching a payload | Verifying Ed25519 signatures |
| Cancel the part that has not been filled | Cancel a withdrawal |
| See where the fee comes from | Quote the fee first |
| See every withdrawal state | Full state machine |
| Get ready for production | Go-live checklist |
