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.
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
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
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
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,PROCESSINGandTIMEOUT— 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
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
-
Open the queue. It is already filtered to
PENDINGand sorted oldest first. Work from the top. -
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.
-
Decide. Approve on the row, or Reject with a reason the customer can act on.
-
Filter to
PROCESSINGonce 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. -
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.
Related
- Withdrawal approval and two-factor policy — whether rows reach this queue at all, and what a customer had to prove to create one.
- The admin panel — the Operations inbox and the shared queue decision controls.