The spot desk: orders, withdrawals and customer tickets
The three admin screens an XT-backed spot book is run from, the permissions that open them, the five crons behind them, and the XT-specific gaps that decide whether a payout leaves.
Three screens carry the day-to-day work of an XT-backed spot book, and none of them is the exchange hub. The hub tells you whether the provider is connected; these tell you what your customers are actually doing with it and where their money has stopped.
| Screen | Path | Nav | What it is |
|---|---|---|---|
| Spot Orders | /admin/finance/order/exchange |
Admin → Finance → Order Management → Spot Orders | Every order the platform has placed on XT. Read-only |
| Withdrawal Records | /admin/finance/withdraw/log |
Admin → Finance → Withdrawal Management → Withdrawal Records | The payout queue. The only screen here that moves money |
| Customer Support | /admin/crm/support |
Admin → Customer Support | Where the consequences of the first two arrive |
Read this page before you grant anyone the permissions in the next section. On an XT install two of these screens behave in ways the interface does not explain, and one of them can refund a customer while looking like it paid them.
The permissions, including the three nobody documents
The Spot Orders screen is gated on access.exchange.order, and its endpoints on
three further keys that no reference page in this product listed until now.
| Permission | Grants |
|---|---|
access.exchange.order |
Opens /admin/finance/order/exchange. Without it the page does not render and the entry is hidden from the Order Management menu |
view.exchange.order |
GET /api/admin/finance/order/exchange and the single-order read. This is the key that makes the table return rows |
edit.exchange.order |
The order update endpoint and both status endpoints, single and bulk |
delete.exchange.order |
The single and bulk delete endpoints |
create.exchange.order |
Seeded, and gates nothing. There is no create route under this path — orders are created by customers on /trade, never by an admin |
The other two screens use keys of their own: access.withdraw and
view.withdraw for the payout queue, edit.wallet for approve and reject,
edit.withdraw for the bulk decision, and access.support.ticket /
view.support.ticket / edit.support.ticket / delete.support.ticket for the
support desk.
The Spot Orders table ships with canCreate, canEdit and canDelete all
false, so that screen writes nothing. It is not the only table bound to this
endpoint. The Spot tab of a customer's record — Admin → Users → Users
(/admin/crm/user), open a user — renders the same exchangeOrder table
against /api/admin/finance/order/exchange with the same permission map and
canEdit and canDelete true. Its Edit action opens the row in a form and
PUTs it, status included, to /api/admin/finance/order/exchange/{id}; its
Delete action issues DELETE /api/admin/finance/order/exchange/{id}. Both are
one dropdown click away for anyone whose role holds the key. Only the two
dedicated status endpoints, single and bulk, are genuinely API-only — no screen
calls them.
Editing and deleting are hazardous for the same reason: an OPEN order is
holding the customer's funds in wallet.inOrder, and the reconciler finds work
by scanning exchangeOrder rows with status = 'OPEN' and a non-null
referenceId. Writing a terminal status by hand, or soft-deleting the row (the
model is paranoid, so a plain delete hides it from that scan), takes the order
out of reconciliation with the hold still in place and nothing left pointing at
it. Grant view.exchange.order freely. Withhold edit.exchange.order and
delete.exchange.order from anyone who works customer records — those two keys
are exactly what turn those row actions from greyed out into live.
Spot Orders
/admin/finance/order/exchange is a record, not a control panel. It lists every
exchangeOrder row with the trader joined, twelve to a page, and the only
action is opening one.
The columns are id, referenceId, the trader, symbol, type
(MARKET / LIMIT), timeInForce (GTC / IOC / FOK / PO), side
(BUY / SELL), price, amount, filled, remaining, cost, fee,
feeCurrency, status and createdAt. Several are collapsed into the expanded
row rather than shown in the grid.
referenceId is XT's own order id. It is the join key between your database
and anything you export from XT's interface, and it is the value the reconciler
passes to fetchOrder.
The five statuses
The status column is an ENUM and holds exactly these:
| Status | Means |
|---|---|
OPEN |
Live on XT. Funds are held in wallet.inOrder |
CLOSED |
Filled and settled into the wallet |
CANCELED |
Cancelled — one L, as the column spells it. The hold was released |
EXPIRED |
XT expired it. The hold was released |
REJECTED |
XT refused it. The hold was released |
PUT .../{id}/status declares an enum of OPEN, CLOSED, CANCELLED and
PARTIALLY_FILLED. Neither CANCELLED (two Ls) nor PARTIALLY_FILLED is in
the model's ENUM, and the model validates on update, so sending either fails
rather than writing anything. The column's spelling is CANCELED.
Opening an order
The view dialog is the one place the full record is visible: the trader
(name, email, user id), pricing including average fill price — which has no
column of its own and is the only field that says what the order actually
executed at — a fills table built from the order's trades, the local id
beside XT's referenceId, the raw settlement metadata, and the timeline.
The tiles above the table, and how to read them on XT
The analytics strip is worth learning because three of its figures are the early warning that the XT connection is degrading.
| Tile | Reads | Caveat |
|---|---|---|
| Notional Traded | Sum of cost on CLOSED orders |
Deliberately not formatted as dollars. cost is priced in the quote half of symbol, and a book carrying USDT and BTC pairs is summing different assets |
| Fee Revenue | Sum of fee on CLOSED orders, grouped by feeCurrency and priced to USD |
The one genuine dollar figure on the page |
| Effective Take Rate | Fee revenue over notional | A ratio of two different units. Read it for movement, never as a rate to quote |
| Traders | Distinct userId, lifetime |
Exact, and deliberately not windowed |
| Resting Exposure | remaining × price summed over OPEN orders |
Same mixed-unit caveat as notional. This is how much customer money is currently held |
| Stale Open Orders (24h) | OPEN orders older than a day |
The XT alarm. A limit XT will never fill, or a fill the reconciler missed |
| Orders Placed | Count over the selected window | The denominator for the rate below |
| Rejected / Expired | Count of REJECTED + EXPIRED |
Rising here means XT is refusing your orders — usually precision or minimum-notional |
| Reject / Expire Rate | The two above as a percentage | |
| Unreconciled Fills | CLOSED orders with a null referenceId |
Settled locally with no XT id to check them against. These cannot be reconciled against an XT statement |
Two rankings sit below: Fee Revenue by Currency (top five, each bar in its own asset — ranked, never summed) and Top Symbols by Settled Notional (top eight).
What settles an order, and the XT quirk in it
processPendingSpotOrders runs every 60 seconds on the cron worker. It sweeps
every OPEN order with a referenceId, calls fetchOrder(referenceId, symbol)
on XT, and acts only on a terminal status or a partial fill. Failures are
isolated per order and logged under the SPOT_RECON module — a stuck order
produces Failed to reconcile spot order <id> (ref <referenceId>) on every tick.
Two behaviours are worth knowing at the desk:
- A market with no
exchangeMarketrow cannot settle. The reconciler resolves the fee rate from the market row for the order's symbol; without it it logs "Market data not found for<symbol>" and abandons that order, permanently, with the hold in place. Never delete a market that still carries open orders. - Buy quantities from XT are in the quote asset. For a
BUY, XT reportsinfo.executedQtyas the amount spent; the platform divides it byinfo.avgPricebefore crediting. This is handled, and it is why thefilledcolumn will not match a raw XT CSV column by column. See Deposits and withdrawals.
Spot Orders is read-only, and the one admin write path that does exist — Edit on
the customer's Spot tab — rewrites the row without touching inOrder, which is
why setting a status by hand strands the hold rather than releasing it.
Adjust Balance on /admin/finance/wallet moves balance only — it does not
touch inOrder either. The two paths that return held funds are the customer's
own cancel and the reconciler, and both must first resolve the order on XT. If
XT cannot resolve the id, there is no supported way to release the hold from the
admin panel.
Withdrawals
The queue itself — how it opens, what Approve does per wallet type, the two 409s, the mandatory rejection reason — is Working the withdrawal queue. Everything below is what changes when the active provider is XT.
The admin approval handler chooses its payout branch from the resolved provider
name. The switch has a case for kucoin, a shared case for binance and
okx, and an empty default:. There is no xt case.
With no withdraw response the handler treats the payout as failed: it credits
the customer back the full debit under the idempotency key
withdraw_approve_refund_<id>, sets the transaction to REJECTED with
failureReason: "No withdrawal response from exchange" in its metadata, and
answers 500 with a message saying the user has been refunded. Nothing leaves
your XT account.
The bulk decision (PUT /api/admin/finance/withdraw/log/status) delegates to
this same handler, so it behaves identically — a batch of forty XT approvals
refunds forty customers and reports forty failures.
This affects only the admin approval path. The customer-initiated
withdrawal route has a full xt branch, including XT's own status vocabulary,
and works.
That leaves one working configuration for spot payouts on XT, and one workaround:
Auto-approve. With withdrawAutoApprove on, the customer's own withdrawal
request calls exchange.withdraw through the XT branch at request time and
never reaches the admin queue.
Both are stored as text: the string "true" is on, anything else is off. The
shipped default is manual review — which is the configuration in which XT
payouts do not work.
Turning it on removes the human step in front of every spot payout on the platform. Weigh that against the alternative below before you flip it, and if you do turn it on, XT's own withdrawal address whitelist becomes your last control — the platform has no equivalent.
Or pay by hand and reject with an explanation. Send the payout from XT's own interface, then reject the platform row with a reason that says so. Rejection refunds the customer, so this only balances if you have separately debited them — it is bookkeeping you must get right yourself. There is no admin action that marks a spot withdrawal paid without calling XT.
The status vocabulary XT answers with
When a withdrawal does dispatch, XT's statuses are mapped onto the platform's:
SUCCESS → COMPLETED, SUBMIT / REVIEW / PENDING → PENDING,
AUDITED / AUDITED_AGAIN → PROCESSING, FAIL → FAILED, CANCEL →
CANCELLED. Anything else is uppercased and used verbatim, which is how an
unfamiliar value reaches this screen unchanged.
The two withdrawal crons
Both were missing from this product's cron table. Both matter at the desk.
| Job | Period | What it does |
|---|---|---|
reconcileSpotWithdrawals |
5 min | Crash recovery. Finds PROCESSING withdrawals on SPOT wallets older than five minutes and resolves them against XT |
processPendingWithdrawals |
30 min | Converges PENDING withdrawals that already carry a referenceId to whatever XT now reports, refunding on FAILED or CANCELLED |
reconcileSpotWithdrawals is the one to understand, because it is the only
thing standing between a crashed dispatch and either a lost payout or a double
one. A PROCESSING row has already been debited, and there are two cases:
referenceIdset —exchange.withdrawsucceeded and the status update was lost. The job converges the row to XT's reported status, refunding atomically (refund first, status second) onFAILEDorCANCELLED. If the id is not in the page XT returns, the row is not refunded: pagination is not evidence of absence.referenceIdnull — the process died somewhere around the withdraw call and the payout may or may not have been sent. Nothing happens for 24 hours. After that the job scans XT's withdrawal history over a window starting an hour before the row was created, and only refunds when that scan positively shows no plausible match on address or amount.
Anything ambiguous is escalated rather than settled: the row stays PROCESSING,
a Super Admin gets an in-app notification titled "Withdrawal Requires Manual
Review", and the backend logs MANUAL REVIEW REQUIRED: with the reason. The
row then backs off exponentially — one hour, doubling, capped at 24 — so a
permanently unresolvable row stops burning XT rate limit. A row that never
refunds a customer is behaving correctly; it is waiting for you.
The reconciler refuses to refund any row with a trxId, because a broadcast
hash is proof the payout left. Spot withdrawals do not write that column — it is
the ecosystem queue's — and this is the guard that stops an ecosystem payout
being refunded into a SPOT wallet after it is already on chain.
The five crons behind this desk
Admin → System → System Monitoring → Scheduled Tasks (/admin/system/cron,
access.cron) shows the last run, the last error and the next scheduled run for
each. Nothing in the System menu is called "Cron" — that word appears only in
the URL. Triggering a job by hand needs manage.cron.
| Job | Period | Effect if it stops |
|---|---|---|
processPendingSpotOrders |
60 s | Orders fill on XT and never settle. Holds stay in inOrder |
processCurrenciesPrices |
120 s | Portfolio values freeze at their last known price |
processSpotPendingDeposits |
15 min | Pending SPOT deposits are never worked through |
reconcileSpotWithdrawals |
5 min | A crash between the debit and the XT send leaves the row PROCESSING forever, with the customer debited |
processPendingWithdrawals |
30 min | Dispatched withdrawals never reach a terminal status |
Every one of them is a no-op while there is no active provider or while the
Redis ban switch exchange:ban_status is set — and a banned install reports
green on all five, because they complete successfully having done nothing. See
Reference.
backend and cron are separate processes by default. These five run in the
cron process only.
The memo hazard, and why XT can never warn you about it
XT publishes no memo metadata. The XT branch of the currency standardiser sets
withdrawMemo: false on every network it builds, with the reason in the code:
withdrawMemo: false, // XT data doesn't have memo information, defaulting to falseIt then goes nowhere. The spot currency import assembles those network rows into
a chains value and never writes it: exchange_currency has columns for
currency, name, precision, price, fee and status, and nothing else.
There is no memo flag in your database for any provider, and on XT there is no
data path that could ever produce one.
What actually decides whether a customer is asked for a destination tag is a
hard-coded list of five tickers in the withdrawal form — XRP, XLM,
EOS, ATOM, HBAR — plus BNB when the selected chain is BEP2 and not
BEP20. For those, the field is mandatory and the form says a missing memo is
unrecoverable.
Enable TON, KAVA, IOST, XEM or any other tag/memo asset on an XT install and the withdrawal form asks for an address and nothing else. The customer's coins arrive at the destination exchange unattributed, and recovering them is that exchange's discretion, not yours.
Before you enable a memo-requiring asset: send one yourself, on the smallest amount the network allows, and confirm it credits at the far end. If it needs a tag the form will not collect, do not list it. The imported data cannot tell you, the admin screens cannot tell you, and the first report will be a customer whose funds are gone.
Deposits are the mirror image: memos are only as good as what the customer is told on the deposit screen, and nothing in the imported data says a memo is required there either. Say it in the currency's description yourself.
Why the desk breaks if the server presents IPv6
One line in the connection layer decides whether any of this works, and it names XT:
// Force IPv4 for exchanges like XT.com that don't support IPv6 whitelisting
const httpsAgentIPv4 = new Agent({ family: 4, keepAlive: true, timeout: 30000 });Every XT connection the platform builds — the live one, the credential test, the
proxy test — uses that agent unless a proxy URL is set on the provider row,
in which case the proxy agent replaces it and the family: 4 pin is gone.
XT's API-key IP allowlist takes IPv4 addresses. A dual-stack server that presents an IPv6 source address is refused on every signed call, and the refusal looks exactly like a bad secret: invalid credentials, on a key you know is correct. That is the failure this pin exists to prevent, and it is why a proxy must itself be IPv4-capable and its own address allowlisted.
If the desk goes dark all at once — orders failing, balances empty, deposits
unverified — check curl -4 https://api.ipify.org from the app server against
what is on the key before you touch anything else. Details in
API keys and network access.
Customer tickets
Support is where the first three sections arrive, usually before your monitoring
does. The desk itself — the queue's ordering rule, the five filters, assignment,
the archive and its bulk verbs — is
Running the support desk. Four statuses,
all answering whose turn is it: PENDING and OPEN are waiting on you,
REPLIED is waiting on the customer, CLOSED is done.
What is worth having ready is the mapping from what a customer says to what to open first.
Almost always the network-name translation, not XT. The platform carries three
separate XT network maps for three different calls, they do not agree, and the
verification map returns null for a chain it does not list.
Check the transaction on /admin/finance/deposit/log first: a row sitting at
PENDING with a referenceId means the coins are at XT and the match failed. A
missing row means the customer never submitted the transaction id. Then check
whether the chain is in the verification map —
Deposits and withdrawals lists all
three. Recovery is manual: confirm the deposit in XT's own interface, then
credit the wallet with Adjust Balance on /admin/finance/wallet.
Open /admin/finance/withdraw/log and read the status and referenceId
together. PROCESSING with a referenceId means XT accepted it and the desk is
waiting on XT. PROCESSING with none means the dispatch crashed, and
reconcileSpotWithdrawals will not touch it for 24 hours by design.
If a Super Admin has a "Withdrawal Requires Manual Review" notification for that transaction, the reconciler has already looked and refused to decide. Resolve it against XT's own withdrawal history, then reject the row — with a reason — if the payout genuinely never went out. Rejection is what returns the money.
processPendingSpotOrders has stopped, or that one order cannot be resolved.
Check Admin → System → Scheduled Tasks for the job's last error, then find the
order on /admin/finance/order/exchange and read its referenceId against XT.
Grep the backend log for SPOT_RECON and the order id.
If the log says "Market data not found", the market row for that symbol has been deleted and the order can no longer settle at all.
Order fees come from your stored maker/taker rates on the market row, not
from XT's. Taker applies to buys and maker to sells. The rate is on
/admin/finance/exchange/market, editable per market, and re-importing does not
refresh it for a symbol you already have — see
Currencies and markets.
Two things to keep out of a reply: the provider's name, and the reason a withdrawal was rejected if you have not established it. Neither is recoverable once sent.
What we could not determine
- Nothing records which provider an
exchangeOrder,walletortransactionrow belongs to. There is no column and no audit entry. After a provider change the only signal is the row's creation date against the date you switched. - The
withdrawMemovalue the XT standardiser computes is not persisted anywhere, so we cannot say what it was originally meant to drive. Nothing in the current codebase reads it. - Whether XT's own API would expose memo requirements through a different call is outside what this platform asks it for.
Related
- Working the withdrawal queue — the queue's own contract, wallet-type routing and the bulk decision
- Running the support desk — queue ordering, statuses, assignment and the archive
- Deposits and withdrawals — the three network maps and the XT status mapping in full
- Currencies and markets — where fees and precision come from
- API keys and network access — IPv4, allowlisting and the proxy path
- Reference — every endpoint, permission, cron and Redis key
- Troubleshooting — symptom-first diagnosis