Working the withdrawal queue

The screen where money irreversibly leaves — what Approve does for each wallet type, which one is refused outright, the two 409s, and what a rejection costs.

7 min readUpdated 6 August 2026withdrawals, payouts, queue, approvals, refunds

Approve on a SPOT row calls the exchange and sends real funds to an address you cannot recall. There is no undo, no reversal and no support line. Everything on this page is written on the assumption that you understand that before you click anything.

The queue is Admin → Finance → Withdrawal Management → Records (/admin/finance/withdraw/log). It opens filtered to PENDING, sorted oldest first — the row that has waited longest is the one to decide next.

The columns are the four facts a payout decision needs: who, how much, where to, and how long it has waited. The Age column is the same number the Operations inbox badge is computed from, so the two cannot disagree. Fee, reference id, transaction hash, description and the raw metadata are available in the expanded row.

Opening a row gives you /admin/finance/withdraw/log/[id], with five tabs: Details, User, Wallet, Manage and Audit Trail. The audit tab is the record of who decided what, and it is append-only.

Approve does three different things

Approves a withdrawal

Note the shape of that path: the {id} is the transaction id, even though the route sits under wallet. What happens next is decided by the type of the wallet the transaction belongs to, and the three behaviours have almost nothing in common.

Wallet type What Approve does
SPOT Claims the row, then executes a real payout through the exchange provider (ccxt)
FIAT Bookkeeping only. Marks it COMPLETED and books the platform fee. You made the bank transfer yourself
ECO Refused. Ecosystem withdrawals are settled on-chain by the ecosystem queue

SPOT — the one that moves money

The row is claimed with an atomic compare-and-set from PENDING to PROCESSING before exchange.withdraw() is called. Only the request that wins that transition is allowed to contact the exchange.

409 Withdrawal is already being processed means another request — your double click, a colleague, a retried bulk run — already claimed the row. It is not an error to work around. Without that claim, two requests would each broadcast a withdrawal of real funds.

If the exchange rejects the withdrawal, or returns no id, or returns FAILED or CANCELLED, the customer is automatically refunded the full debited amount (under idempotency key withdraw_approve_refund_<id>), the transaction is set to REJECTED with the failure reason in its metadata, and you get a 500 whose message says the user has been refunded. On success the transaction takes the exchange's status and stores the exchange's withdrawal id in referenceId.

FIAT — recording a payment you already made

A manual fiat payout is bookkeeping. The customer's wallet was debited when they requested the withdrawal; you have since moved the money by bank transfer; Approve records that. It flips the status to COMPLETED and books the platform fee.

If the transaction's metadata carries a transfiOrderId, or its referenceId begins with OR-, the withdrawal has already been dispatched to a payout provider. Approve is refused with a 409 that names the provider reference:

This withdrawal is being executed by a payout provider and cannot be approved by hand. Its status is set by the provider webhook or the payout reconciler. Provider reference: OR-…

Do not go looking for another way to close it. The provider decides when funds land, and the failure webhook would later refund a customer you had already marked paid — creating money out of nothing.

ECO — refused, and correctly so

Refused for ecosystem wallets

Ecosystem withdrawals are settled on-chain by the ecosystem queue and cannot be approved here.

The ecosystem's own withdrawal queue owns the nonce and the broadcast. Approving from the admin panel would mark the withdrawal paid without anything leaving the wallet. Rejection, however, works for ECO — see below.

The platform fee is booked at settlement

Never at request time. The withdrawal route records the platform's portion in the transaction's metadata.fee and takes nothing; collectWithdrawalFeeOnSettlement credits it when the withdrawal actually completes.

That ordering exists because the alternative minted money: taking the fee up front meant a later rejection refunded the customer the full debit — including the fee — while the fee sat credited to the platform. Every rejection created currency out of nothing.

Rejecting

Rejects a withdrawal and refunds the customer

Rejection is the same for every wallet type, and it is the action that un-freezes a stuck payout.

  • A reason is mandatory, minimum 3 characters. It is emailed to the customer and stored on the transaction's metadata as note. An empty reason produces the "my withdrawal was declined with no explanation" support ticket the whole reason-capture rule exists to prevent. Approval, by contrast, takes an optional note.
  • The customer is refunded, amount plus fee. SPOT and FIAT go through a standard credit; ECO goes through ecoRefund, which updates the chain balance as well as the wallet balance.
  • It accepts PENDING, PROCESSING and TIMEOUT — one status more than Approve. A payout that started and did not finish is exactly the row that needs a human, and rejecting it is what returns the customer's money.

Deciding in bulk

Bulk approve or reject

Body: { ids, status, reason }, where status is COMPLETED or REJECTED. A reason of at least 3 characters is required for REJECTED, and it is shared across the whole selection.

Two things make this safe to use:

It delegates. Each id is handed to the very same single-row handler you would have used, so the wallet-type routing, the atomic claim, the auto-refund, the fee accounting and the customer email all come along and cannot drift from the single-row behaviour.

Partial success is reported, not rolled back. Approving forty withdrawals is forty independent money movements. If three fail on exchange balance, the thirty-seven that already left must not be undone — and pretending the whole batch failed would send you to re-approve payouts that have been made. The response is:

{
  "message": "37 of 40 processed. 3 could not be: …",
  "succeeded": 37,
  "failed": 3,
  "failures": [{ "id": "…", "error": "…" }]
}

If nothing succeeded, the whole call returns a 400 instead, so a green toast can never mean "no money moved".

Both the row buttons and the bulk menu are built from one configuration, so they can never disagree about which statuses are actionable. Before touching any money, the bulk handler re-reads each transaction's current status from the database and refuses anything that is not PENDING or PROCESSING with Already <STATUS>.

That check is what stops a stale browser tab from re-approving a COMPLETED payout. Your selection was made against the list as it was rendered; the withdrawal may have been settled by a colleague, by the auto-approve path, or by a reconciler since. Rows already settled get no buttons on the row itself either — offering Approve on a completed withdrawal is an invitation to pay twice.

Do not use the bookkeeping PUT to approve

There is a second route, PUT /api/admin/finance/withdraw/log/{id}, that also sets a status. It is bookkeeping: it flips the status and books the fee without sending anything anywhere, and it accepts PENDING only. Using it to "approve" a SPOT withdrawal marks the customer paid while no funds move, which is how payouts end up being executed by hand outside the product.

The detail screen already routes correctly for you — its Approve button calls the exchange path for SPOT, the bookkeeping path for FIAT, and refuses ECO with an explanation. This note is for anyone driving the API directly.

The daily routine

  1. Open the queue. It is already filtered to PENDING and sorted oldest first. Work from the top.

  2. Read the destination, not just the amount. The address or bank detail is a first-class column for a reason. It is the part that cannot be corrected after the fact.

  3. Decide. Approve on the row, or Reject with a reason the customer can act on.

  4. Filter to PROCESSING once a day. Those are payouts that started and did not finish. Nothing else in the product will tell you about them, and Reject is what returns the money if one is genuinely stuck.

  5. Check the Audit tab when a row looks wrong. It carries who did what to that specific transaction, including failed attempts.

The Operations inbox in the admin header gives withdrawals a 7-day budget and turns the queue amber at half of it. See The admin panel for how the inbox is built.