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.

13 min readUpdated 6 August 2026orders, withdrawals, tickets, permissions, cron

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
Lists exchange orders with the trader joined. Masks the customer email in demo mode
Reads one order
Updates referenceId, status, side, price, amount, fee and feeCurrency on one order
Sets one order's status
Sets many orders' status
Deletes one order. Soft by default; force=true is permanent, restore=true undoes a soft delete
Bulk deletes orders

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 exchangeMarket row 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 reports info.executedQty as the amount spent; the platform divides it by info.avgPrice before crediting. This is handled, and it is why the filled column 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.

When on, spot withdrawals execute immediately through the provider. When off, they wait in the queue for a decision. Admin → System → Settings → Wallet → Security → Auto-Approve Withdrawals. Changing it requires a Super Admin
The superseded key. Read only when the new one has never been saved. Same screen, labelled Auto-Approve Withdrawals (Legacy)

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: SUCCESSCOMPLETED, SUBMIT / REVIEW / PENDINGPENDING, AUDITED / AUDITED_AGAINPROCESSING, FAILFAILED, CANCELCANCELLED. 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:

  • referenceId setexchange.withdraw succeeded and the status update was lost. The job converges the row to XT's reported status, refunding atomically (refund first, status second) on FAILED or CANCELLED. If the id is not in the page XT returns, the row is not refunded: pagination is not evidence of absence.
  • referenceId null — 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 false

It 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 formXRP, 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, wallet or transaction row 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 withdrawMemo value 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.