The spot desk — orders, withdrawals and customer tickets

The admin screens you open when a spot customer complains — the order table, the withdrawal queue, the exchange hub, balances, charts and fees, and what each one can and cannot do.

13 min readUpdated 6 August 2026orders, withdrawals, approval, balances, support

A customer says their order is stuck, or their payout has not arrived. This page is the map of the screens that answer that, in the order you would actually open them — and, just as usefully, the list of things those screens will not do for you.

POST /api/admin/finance/wallet/{id}/withdraw/approve calls withdraw on your Binance account. There is no confirmation step beyond the button, no reversal, and the funds leave the account that backs every other customer's balance. Read Float, treasury and reconciling the ledger before you start approving in volume.

Where the screens are

Five of these have a menu entry. The other four — Markets, Charts, Balances and Fees — are reached from the exchange hub's Quick Actions grid or by typing the URL.

Screen Path Menu
Exchange hub /admin/finance/exchange Finance → Trading Infrastructure → Exchange Providers
Spot orders /admin/finance/order/exchange Finance → Order Management → Spot Orders
Withdrawal queue /admin/finance/withdraw/log Finance → Withdrawal Management → Withdrawal Records
Wallets /admin/finance/wallet Finance → Transaction Management → Wallet Management
Transactions /admin/finance/transaction Finance → Transaction Management → Transaction Logs
Markets /admin/finance/exchange/market — hub Quick Actions
Charts /admin/finance/exchange/chart — hub Quick Actions
Exchange balances /admin/finance/exchange/balance — hub Quick Actions
Fee comparison /admin/finance/exchange/fee — hub Quick Actions

The permission keys for each are in Permissions, background jobs and Redis keys.

Spot orders

/admin/finance/order/exchange — one row per exchangeOrder, joined to the customer.

Lists spot orders with the trader joined. Paranoid, so soft-deleted rows are excluded unless you ask for them.
Opens one order.

On the row: the trader (avatar, name, email), symbol, type (MARKET / LIMIT), side (BUY / SELL), price, amount and status.

Behind the expander and in the view dialog: id, referenceId, timeInForce, filled, remaining, cost, average, fee, feeCurrency, trades, metadata, createdAt, updatedAt, deletedAt. The view dialog adds a Fills table built from trades and an Order reference section pairing your id with Binance's referenceId — the id you would quote to Binance support.

status is one of OPEN, CLOSED, CANCELED, EXPIRED, REJECTED. Note the single L in CANCELED; that is the column's enum.

This screen is read-only

canCreate, canEdit and canDelete are all false on the table. There is no form, no per-row edit and no per-row delete. What remains:

  • View — the detail dialog.
  • Delete Selected — selecting rows and using the Actions menu still offers the built-in bulk delete, gated on delete.exchange.order. The table is paranoid, so this is a soft delete; the Show deleted toggle exposes Restore and Permanent delete.

Deleting an order row deletes a record. It does not cancel anything at Binance and it does not release the customer's hold.

An OPEN order is holding funds in the customer's wallet.inOrder. Two code paths move money out of inOrder and back to balance, and neither is an admin action: the customer's own cancel on the trade screen, and the processPendingSpotOrders cron, which releases the hold when Binance reports the order CANCELED, EXPIRED, REJECTED or partially filled.

Adjust Balance on /admin/finance/wallet moves balance only. It does not touch inOrder. If a hold is genuinely orphaned, the honest fix is to find out what happened at Binance using the order's referenceId and then correct balance — the inOrder figure will still be wrong.

The API does carry write routes that the screen does not expose. If you reach for them directly, know that their accepted status values do not match the column:

Updates one order's status. Its schema accepts OPEN, CLOSED, CANCELLED and PARTIALLY_FILLED.
Bulk status update. Its schema accepts OPEN, CLOSED and CANCELLED.
Updates an order's fields. Requires the whole documented body.
Bulk soft-delete by id.

The two status routes advertise CANCELLED (two Ls) and PARTIALLY_FILLED. Neither is a member of the exchangeOrder.status enum, and the request body is validated against the advertised list before it reaches the database — so the one spelling the column accepts, CANCELED, is rejected by the route, and the spellings the route accepts are rejected by the column. Treat both routes as unusable for anything other than OPEN and CLOSED.

The analytics tab is the useful part

Four figures on the Analytics tab answer the questions a ticket usually raises:

Card What it means
Resting exposure remaining × price summed over OPEN orders. The money currently held. Not in dollars — it is in whatever quote assets your symbols use, and they are summed together
Stale open orders (24h) OPEN orders placed more than 24 hours ago. Either limits Binance will never fill, or fills the reconciler missed
Unreconciled fills CLOSED orders with a null referenceId — closed locally with no exchange id to point at
Fee revenue by currency A ranking, per feeCurrency. Bars are in different assets and are never summed

Notional traded and Settled notional deliberately carry no currency symbol: cost is priced in the quote half of a free-text symbol, so a book that settled 50,000 USDT and 2 BTC cannot honestly be added up.

The withdrawal desk

/admin/finance/withdraw/log opens filtered to status = PENDING, sorted oldest first — the row that has waited longest is the one to decide next.

Lists withdrawal transactions with the wallet and user joined.

Columns: the customer, Age (from createdAt), status, amount, Wallet — rendered as BTC (SPOT), which is how you tell a spot payout from an Ecosystem or fiat one, because there is no wallet-type filter — Destination (the address and chain pulled out of metadata, or a masked bank account for fiat), fee, referenceId, trxId, description and metadata.

Decisions are available from two surfaces, and they take different permission keys:

  • From the list — the Approve and Reject entries on a PENDING or PROCESSING row, and the same pair in the bulk menu for a selection. Both raise the same confirmation dialog and both send PUT /api/admin/finance/withdraw/log/status, so both need edit.withdraw. Rejecting always demands a reason; approving does not.
  • From the detail page /admin/finance/withdraw/log/{id} — the buttons there call the wallet routes directly and need edit.wallet.

The list route does not reimplement anything: it hands each id to the very same approve and reject handlers, so the wallet-type routing, the fee accounting, the idempotency key and the customer email cannot drift between the two surfaces. Partial success is reported, not rolled back — the response names which ids failed and why, because forty approvals are forty independent money movements.

Approves and pays out one withdrawal.
Rejects and refunds one withdrawal. A reason of at least three characters is required and is emailed to the customer.
Bulk approve or reject. Only PENDING and PROCESSING rows are decidable.

{id} is a transaction id, not a wallet id, despite the path.

What Approve does, in order

  1. Loads the transaction and reads the wallet behind it to decide the branch: ECO is refused, FIAT is bookkeeping, everything else is the spot path.

  2. Refuses anything not PENDING with 400 Transaction is not pending.

  3. Checks the balance, refusing when the amount plus the network fee exceeds wallet.balance.

  4. Claims the row atomically — a single UPDATE ... WHERE status = 'PENDING' to PROCESSING. Only the request that wins the compare-and-set is allowed to call the exchange, so a double-click or a retry gets 409 Withdrawal is already being processed rather than a second payout.

  5. Calls withdraw on Binance with the destination address, the memo or tag, and { network: chain }.

  6. Maps the result: completed or okCOMPLETED, canceledCANCELLED, failedFAILED, anything else stays PENDING. The withdrawal id is stored as referenceId.

  7. Refunds on failure. No withdrawal id, or a failed/cancelled status, credits the full debited amount back under the idempotency key withdraw_approve_refund_<transactionId>, marks the transaction REJECTED with a failureReason in metadata, and answers 500. The customer was debited when they made the request, so not refunding would destroy the money.

Status codes are preserved rather than flattened to 500 — a 400 or 409 means "refused for a permanent reason, do not retry".

Approving an ECO withdrawal answers:

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

That is the exact message an operator sees if they try. Ecosystem payouts are broadcast by the ecosystem's own withdrawal queue, which owns the nonce and the broadcast; hand-completing one would mark it paid without anything leaving. The detail page blocks it client-side too, before the request is sent.

FIAT rows take the bookkeeping path instead: the status flips to COMPLETED and the platform fee is booked at settlement. That is correct — you really did pay by bank and are now recording it — but a row already dispatched to a payout provider is refused with a 409 naming the provider reference.

The fee added at approval

The handler looks for the withdraw fee of the chain named in the transaction's metadata — currency.chains[].withdrawFee on the exchangeCurrency row — and adds it to the amount before the balance check.

There is nothing there to find. exchangeCurrency has exactly seven columns — id, currency, name, precision, price, fee, status — and no chains. The spot currency import computes a standardised per-network table and then writes only currency, name, precision, fee and (on creation) status. So the network fee resolved at approval is always 0, and the guard reduces to "amount must not exceed wallet.balance".

The spot branch reads currency, chain, address and memo out of transaction.metadata. That column is TEXT holding JSON, the transaction model defines no getter for it, and this handler does not parse it — unlike the reject handler and the detail page, which both parse it explicitly. The customer withdrawal route also writes the destination as toAddress, not address.

We verified that by reading the code, not by running a payout on a live account. Put a small real withdrawal through Approve on your own install before you promise customers same-day payouts, and know the fallback if it fails: Reject refunds the customer correctly (it parses the metadata properly and accepts PENDING, PROCESSING and TIMEOUT), and you can pay them from Binance's own interface and record it separately. Turning on auto-approval bypasses this handler entirely — the customer route does its own payout — but it also removes the review step, which is a much bigger decision.

What Reject does

Reject accepts PENDING, PROCESSING and TIMEOUT, which is what un-freezes a payout stranded mid-flight. It requires a reason, stores it on the transaction as metadata.note, credits the amount plus the fee back to the wallet, and emails the customer with the reason. ECO rows refund through ecoRefund so the chain balance is corrected too; everything else uses a plain credit.

The exchange hub

/admin/finance/exchange is the provider's own screen. It has three tabs:

Tab Contents
Overview The provider blurb. Nothing actionable
Regions For Binance, a link to Binance's own country-region selector. KuCoin and XT instead list restricted countries
Settings Three status tiles — Status (active/inactive), License (licensed/unlicensed), Version — and the proxy form

Verify Credentials sits in the header and is available on every tab.

Round-trips the saved credentials against Binance with a throwaway connection. Not on the audit trail.

The result renders as a banner under the tabs. On failure it prints the literal APP_BINANCE_API_* variable names it expected and reminds you that .env changes need a restart. A green result proves your .env is correct; it does not prove the running processes are using it — see Switching the active exchange provider.

The proxy field accepts http://, https://, socks4:// and socks5://. Credentials embedded in the URL are masked on read as //***:***@, so the value you see after saving is not the value you typed. Test Proxy opens a separate connection and does not disturb the running one; save only after a successful test. Saving also evicts the cached connection in that process, so the next request rebuilds through the proxy.

The Quick Actions grid links to Markets, Charts, Currencies, Balances and Fees. Balances and Fees are disabled until credential verification passes, because both fail with an authentication error otherwise.

If no provider is enabled at all, the page replaces itself with "No active exchange provider" and a link to Admin → System → Extensions, filtered to exchanges — which is where the on/off toggle actually lives.

Exchange balances

/admin/finance/exchange/balanceasset, available, inOrder, total, read live from fetchBalance on every load. Searchable, sortable and paginated client-side.

Live balances on your Binance account, filtered to non-zero holdings.

Three failures have distinct codes, and they mean different things:

Code Message Meaning
401 Authentication error: please check your API credentials. ccxt raised an authentication error — bad key, bad secret, or an IP that is not allowlisted
503 Network error: unable to reach the exchange. ccxt raised a network error — DNS, firewall, proxy, or Binance being unreachable
500 Failed to retrieve exchange balance Everything else: startExchange() returned nothing — no active provider, missing credentials, or a ban key set in Redis — or the balance call failed for some other reason

That last one is the important one, and it is also the least informative. The route does raise its own Exchange or provider not available when startExchange() returns nothing, but it raises it inside its own try, and the catch only recognises ccxt's authentication and network errors — everything else is re-wrapped as 500 Failed to retrieve exchange balance. So that message never reaches the browser, and a 500 here tells you only that it was not a credential or a connectivity problem. With everything else looking healthy it is usually the rate-limit ban rather than a broken screen; check the ban key, not the message.

Rows where available and inOrder are both zero are filtered out of the response. An asset your customers hold in the ledger but your account no longer holds at all does not appear as a zero — it simply is not there. See Float, treasury and reconciling the ledger.

Charts

/admin/finance/exchange/chart lists every market with its interval count, candle count, gap count, cache size and date range, and carries four actions.

Action Endpoint Notes
Build charts POST .../chart/build Takes symbols, intervals, a day count and a per-request delay. Disabled while a ban is active or a build is running
Clean POST .../chart/clean Per selected market. The dialog always clears both Redis and the gzipped files; the endpoint can do either alone
Fix gaps POST .../chart/fix One symbol and one interval at a time, with a delay and a maximum gap count
Settings PUT .../chart/settings Cache days, rate limit, intervals and auto-update, stored as JSON in the chart_cache setting

A build streams its progress to the page over a WebSocket at /api/admin/finance/exchange/chart/build (manage.exchange.chart). Closing the page stops the monitoring, not the job.

The stat tiles read Total markets, Cache size, Intervals and Cache days. The banner above them is where a rate-limit ban becomes visible in the panel at all. For the cost of a large build, see Currencies, markets and charts.

Fees

/admin/finance/exchange/fee groups by feeCurrency and gives you, per currency, the total amount traded, the fee your platform calculated, the fee Binance's own maker/taker rate implies, and the difference.

Compares platform fees against Binance's market rates, grouped by fee currency.

The query is exchangeOrder.findAll({ where: { userId: user.id } }) — the id of the signed-in admin. It is not a platform-wide fee report; it is a comparison across the orders that admin account placed itself. On an owner account that has never traded, it is empty and correct.

For platform-wide fee revenue, use the Fee revenue by currency ranking on the spot order table's Analytics tab, which is grouped over every row.

The paper trail

When a ticket needs evidence rather than an action:

  • /admin/finance/transaction — every money movement, filterable by type (DEPOSIT, WITHDRAW, EXCHANGE_ORDER, REFUND, PAYMENT, and the rest) and status (PENDING, COMPLETED, FAILED, CANCELLED, EXPIRED, REJECTED, REFUNDED, FROZEN, PROCESSING, TIMEOUT). referenceId is the exchange's id; trxId is the chain hash; metadata holds the rest. Rows can be deleted, not edited.
  • /admin/finance/wallet — one row per customer per currency per wallet type. balance is spendable, inOrder is held. The Adjust Balance row action adds or subtracts from balance with a description, an optional customer notification and a per-dialog nonce so a network retry is de-duplicated while two deliberate adjustments are not.
  • Admin → System → Cron — whether the reconcilers have actually been running.

A ticket, end to end

  1. Find the customer's row. Search /admin/finance/transaction by email and filter to the type they are asking about.

  2. Read the status against what it means. PENDING on a withdrawal means it is in your queue. PROCESSING means the exchange was asked and the answer has not been recorded — that is reconcileSpotWithdrawals's job, on a five-minute cadence. REJECTED with refunded: true in metadata means they already have their money back.

  3. If it is an order, open it on /admin/finance/order/exchange and take the referenceId. That is the id Binance knows it by, and the only thing worth quoting to Binance support.

  4. Check the connection before you conclude anything. A ban or a degraded connection makes every reconciler a silent no-op, so "stuck since Tuesday" is often one Redis key rather than one customer. See Connection, rate limits and bans.

  5. Decide, don't edit. Approve or Reject from the withdrawal desk. Editing a status by hand leaves the money where it was.

What we could not determine

  • Nothing on any screen records which admin approved a given withdrawal in a place the withdrawal row itself exposes. The action is on the admin audit trail (logModule: ADMIN_FIN, logTitle: "Approve Withdrawal"), not on the transaction.
  • The spot order table's metadata and trades columns are rendered raw in the view dialog. Their shape is written by the order settlement path and is not documented as a stable contract.