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.

9 min readUpdated 3 September 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 → Withdrawal 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.

  1. Age - the oldest row is your queue health

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

POST/api/admin/finance/wallet/{id}/withdraw/approvepermission: edit.wallet
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.

Before the claim, the route checks the row carries no sign of a dispatch (an exchange reference, an on-chain hash, a dispatched-but-unconfirmed marker). A row that does is refused with a 409 rather than sent a second time.

What is sent is read off the row: the wallet's currency, the destination and network the customer entered, and the amount they asked for, with the network fee applied under the same withdrawChainFee policy the request was quoted with. (Until Core 6.7.6 this route read a currency field the request never wrote, so every manual spot approval failed with This withdrawal has no currency recorded on it; behind that error it would have sent the gross debit plus the chain fee and refused any customer whose balance was below the withdrawal it had already been debited for.)

If the exchange rejects the withdrawal, or returns no id, or returns FAILED or CANCELLED, the customer is automatically refunded exactly what the request debited (metadata.totalDebit, 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. A timeout on the exchange call is not a failure: the coins may have left, so the row stays PROCESSING for the reconciler and nothing is refunded. On success the transaction takes the exchange's status (pending at the exchange is stored as PROCESSING, never PENDING), stores the exchange's withdrawal id in referenceId, the on-chain hash in trxId when the exchange returns one, and books the platform fee.

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

POST/api/admin/finance/wallet/{id}/withdraw/approve
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

POST/api/admin/finance/wallet/{id}/withdraw/rejectpermission: edit.wallet
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, capped at what the request actually debited (metadata.totalDebit), because amount is editable on the transaction screen and an inflated figure must not credit more than was taken. SPOT and FIAT go through a standard credit; ECO goes through ecoRefund, which updates the chain balance as well as the wallet balance.
  • The status flip and the refund happen in one database transaction. The flip is a compare-and-set against the status the screen showed you, so if the withdrawal queue claimed the row in the meantime the reject answers 409 and nothing moves. Before Core 6.7.6 the status was written first and the refund after, outside any transaction, so a refund that failed left the row REJECTED with the money still debited and no way to retry.
  • It refuses anything a chain or provider may already have. A row that is PROCESSING or TIMEOUT, or that carries an on-chain hash, a pending hash or a provider reference, answers 409. TIMEOUT in particular is what the Solana, TRON and Monero senders write when the transaction was broadcast and its outcome could not be read, so rejecting it used to refund a payout that was landing on-chain. Those rows belong to the withdrawal queue's recovery and the reconciler; rejection is for rows that never left.

Deciding in bulk

PUT/api/admin/finance/withdraw/log/statuspermission: edit.withdraw
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.

Its REJECTED branch refuses, with a 409, any row that a provider has already been handed (an exchange reference, an on-chain hash, a dispatched marker). Until Core 6.7.6 it checked only that the row was PENDING, and the spot route used to store the exchange's own pending as PENDING with the exchange's id attached, so an operator could refund a payout the exchange was in the middle of broadcasting. Such rows are now written PROCESSING and belong to the reconciler.

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.