Skip to content

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 complete

Four 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.filled arrives 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 filled can still be reversed if the after-the-fact check finds that slip unusable. You need a handler for withdrawal.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)eventversionfilled_minorremaining_minorstatus
03:02:11.508withdrawal.created10100000open
03:14:26.118withdrawal.matched (300 reserved)2070000open
03:19:47.985withdrawal.filled #133000070000partially_filled
03:21:05.220withdrawal.matched (400 reserved)43000030000partially_filled
03:33:12.741withdrawal.filled #257000030000partially_filled
03:40:58.771withdrawal.matched (300 reserved)6700000partially_filled
03:52:40.096withdrawal.filled #371000000settled
03:52:40.102withdrawal.settled81000000settled

Two things here are counter-intuitive:

  1. remaining_minor does not drop on filled; it drops on matched. remaining means "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" into filled_minor — and remaining does not move at all.
  2. status becomes settled on the third filled, not on the summary event. withdrawal.settled arrives afterwards with a higher version. Do not wait for the summary event before reading status.

All four payloads ​

1 — withdrawal.filled, chunk 1 (THB 300) ​

json
{
  "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) ​

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

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

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

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

chunkamountchunk fee_recognized_minorrunning total on the order
#130000802802
#24000010701872
#3300008032675
100000total 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 it

Rounding 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.

DataStore inCorrect statementWhy
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 chunkpg_withdrawal_fills (PK = leg_ref)INSERT ... ON CONFLICT (leg_ref) DO NOTHING🔴 append, never overwrite · a redelivery must not add a second row
Wallet movementspg_balance_txns (PK = btxn_id)INSERT ... ON CONFLICT (btxn_id) DO NOTHINGa balance_transaction is immutable and has no version, so dedupe by id
Events already processedpg_webhook_inbox (PK = event_id)inbox + outbox commit in one transactionreplay protection · keep at least 31 days
sql
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) ​

js
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":

sql
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 THB

The end user is Thai, so ship the Thai strings below; the English in parentheses is for you, not for the screen.

statusmessage 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.

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

PieceAmountWhere it is nowRecoverable?
Successfully filled700.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 filled300.00 (30000)returned to your available_minor✅ automatic
Fee actually used18.72 (1872)kept, in proportion to the 70% that was paid out❌
Fee never used8.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 toGo to
Get the version guard right the first timeOrdering and the version guard
Verify the signature before touching a payloadVerifying Ed25519 signatures
Cancel the part that has not been filledCancel a withdrawal
See where the fee comes fromQuote the fee first
See every withdrawal stateFull state machine
Get ready for productionGo-live checklist

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