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.
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.
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:
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.
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
PENDINGorPROCESSINGrow, and the same pair in the bulk menu for a selection. Both raise the same confirmation dialog and both sendPUT /api/admin/finance/withdraw/log/status, so both neededit.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 neededit.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.
{id} is a transaction id, not a wallet id, despite the path.
What Approve does, in order
-
Loads the transaction and reads the wallet behind it to decide the branch:
ECOis refused,FIATis bookkeeping, everything else is the spot path. -
Refuses anything not
PENDINGwith400 Transaction is not pending. -
Checks the balance, refusing when the amount plus the network fee exceeds
wallet.balance. -
Claims the row atomically — a single
UPDATE ... WHERE status = 'PENDING'toPROCESSING. Only the request that wins the compare-and-set is allowed to call the exchange, so a double-click or a retry gets409 Withdrawal is already being processedrather than a second payout. -
Calls
withdrawon Binance with the destination address, the memo or tag, and{ network: chain }. -
Maps the result:
completedorok→COMPLETED,canceled→CANCELLED,failed→FAILED, anything else staysPENDING. The withdrawal id is stored asreferenceId. -
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 transactionREJECTEDwith afailureReasonin 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.
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/balance — asset, available, inOrder, total,
read live from fetchBalance on every load. Searchable, sortable and paginated
client-side.
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.
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 bytype(DEPOSIT,WITHDRAW,EXCHANGE_ORDER,REFUND,PAYMENT, and the rest) andstatus(PENDING,COMPLETED,FAILED,CANCELLED,EXPIRED,REJECTED,REFUNDED,FROZEN,PROCESSING,TIMEOUT).referenceIdis the exchange's id;trxIdis the chain hash;metadataholds the rest. Rows can be deleted, not edited./admin/finance/wallet— one row per customer per currency per wallet type.balanceis spendable,inOrderis held. The Adjust Balance row action adds or subtracts frombalancewith 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
-
Find the customer's row. Search
/admin/finance/transactionby email and filter to the type they are asking about. -
Read the status against what it means.
PENDINGon a withdrawal means it is in your queue.PROCESSINGmeans the exchange was asked and the answer has not been recorded — that isreconcileSpotWithdrawals's job, on a five-minute cadence.REJECTEDwithrefunded: truein metadata means they already have their money back. -
If it is an order, open it on
/admin/finance/order/exchangeand take thereferenceId. That is the id Binance knows it by, and the only thing worth quoting to Binance support. -
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.
-
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
metadataandtradescolumns are rendered raw in the view dialog. Their shape is written by the order settlement path and is not documented as a stable contract.
Related
- Float, treasury and reconciling the ledger
- Deposits and withdrawals — the customer-side mechanics behind these queues
- Permissions, background jobs and Redis keys
- Troubleshooting