The spot desk: orders, withdrawals and customer tickets

The three admin screens you run a KuCoin-backed spot book from — the order table and what it cannot do, the withdrawal queue and its Main-to-Trade transfer, and the tickets both generate.

15 min readUpdated 6 August 2026orders, withdrawals, approval, support, kucoin

With KuCoin active, the daily work of running spot lives on three screens and one read-only balance page:

Screen Path What it is
Exchange orders /admin/finance/order/exchange Every order your platform has forwarded to KuCoin
Withdraw log /admin/finance/withdraw/log The payout queue. Money leaves the building here
Support desk /admin/crm/support The tickets the other two generate
Exchange balance /admin/finance/exchange/balance What your KuCoin account actually holds

None of them is a trading terminal. Your platform does not match orders — it forwards them to KuCoin and mirrors the result — so every screen here is either a record of something KuCoin did, or a decision that makes the platform ask KuCoin to do something.

Read this before you approve anything

Two KuCoin facts change what the numbers on these screens mean, and both are invisible from the screens themselves.

/admin/finance/exchange/balance calls fetchBalance() with no arguments. On KuCoin that resolves to the Trade account — ccxt maps its default spot account type to trade — so the figures on that screen are your Trade balance, not your total holding and not the account a payout draws from.

The withdrawal path moves funds main → trade before it calls withdraw, and aborts if that transfer does not return an id. So the balance screen can read perfectly healthy while every withdrawal fails at step one, because the money it is showing you is already on the side the transfer is trying to move funds into.

Keep a working float in the Main account. Check it in KuCoin's own interface — no admin screen in this product shows it.

The second fact is the ban marker. A RateLimitExceeded from KuCoin writes exchange:ban_status into Redis, and while it is set startExchange() returns nothing at all. The reconciliation cron logs "Exchange is banned; skipping spot reconciliation tick" and does nothing; approvals fail inside their transfer step. So a desk that looks frozen — nothing settling, every approval erroring on a transfer — is the ban marker before it is anything else. Troubleshooting has the command to read and clear it.

The order book

Admin → Finance → Orders → Exchange (/admin/finance/order/exchange).

Lists exchange orders with the trader joined. Soft-deleted rows are excluded unless you ask for them.

The route is gated on access.exchange.order; the table declares view.exchange.order, create.exchange.order, edit.exchange.order and delete.exchange.order. Grant access. and view. to anyone who works the desk. The other three are discussed below, and the short version is: do not grant them.

What the columns mean

The table is one row per exchangeOrder. Columns marked expanded only appear when you open the row or the view dialog.

Column Meaning
id (expanded) Your platform's order id. This is the id in wd_-style log lines and in support threads
referenceId (expanded) KuCoin's order id. The only handle that resolves on the exchange side
user Trader — avatar, name, email. The email is partially masked when the install runs with NEXT_PUBLIC_DEMO_STATUS="true"
symbol BASE/QUOTE, free text copied from the market row
type MARKET or LIMIT
timeInForce (expanded) GTC, IOC, FOK or PO
side BUY or SELL
price The order's limit price. Zero-ish on a market order
amount Quantity in the base asset
filled / remaining (expanded) How much executed and how much did not
cost (expanded) price × filled, denominated in the quote asset
fee / feeCurrency (expanded) Fee charged and the asset it was charged in — base on a BUY, quote on a SELL
status See below
createdAt (expanded) When the order was placed

The view dialog adds three fields that have no column of their own and are the ones worth opening a row for: average fill price, the fills table parsed out of the trades JSON, and the raw per-order metadata (which carries the holdMode flag settlement branches on).

The five statuses

exchangeOrder.status is a database ENUM with exactly these values. Anything else you see quoted at you is a KuCoin status that has not been normalised yet.

Status Means
OPEN Live at KuCoin. Funds are held in the trader's wallet.inOrder
CLOSED Terminal. Filled, or closed with an unfilled remainder that was released
CANCELED Terminal — one L. Cancelled at KuCoin or by the trader
EXPIRED Terminal
REJECTED Terminal

How an order stops being OPEN

Nothing on this screen settles an order. The reconciler does.

processPendingSpotOrders runs every 60 seconds, sweeps every row with status = OPEN and a non-null referenceId, and calls fetchOrder(referenceId, symbol) against the active exchange. What KuCoin answers decides everything: a full fill credits the proceeds and books the platform fee, a cancel or expiry releases the held remainder, a partial does both. All of it is idempotent, so the cron, the trade screen's WebSocket and the trader's own cancel cannot double-credit each other.

Three log lines under SPOT_RECON are worth grepping for when an order will not clear:

  • Failed to reconcile spot order <id> (ref <referenceId>) — KuCoin refused the lookup. Most often the reference belongs to a different exchange (see Switching providers) or the connection is down.
  • Market data not found for <symbol> (order <id>) — the exchange_market row for that symbol is gone, so the fee rate cannot be resolved. The order is abandoned on every tick and the hold stays.
  • Wallet missing for user <id> on <symbol> — one leg of the pair has no SPOT wallet row.

An order KuCoin archived — cancelled or expired with no executions, over 90 days ago — is detected by its error message and settled locally as CANCELED with a full release, so those do not accumulate.

The screen is deliberately read-only, and the endpoints behind it are not

canCreate, canEdit and canDelete are all false on this table. There is no button that changes an order. That is correct, and it is worth knowing why, because the API routes those permission keys unlock do exist:

DELETE /api/admin/finance/order/exchange/{id} soft-deletes the row. It does not cancel anything at KuCoin and does not release wallet.inOrder. The order stays live on the exchange, the customer's funds stay held, and the reconciler can no longer see the row that would have released them. The hold becomes permanent.

PUT /api/admin/finance/order/exchange/{id}/status writes a status straight onto the row with no wallet movement at all. Its request schema also offers CANCELLED and PARTIALLY_FILLED, neither of which is in the model's ENUM, so those two are rejected by validation while OPEN and CLOSED go through and silently desynchronise the row from KuCoin.

Neither route is reachable from the admin panel today. Granting the keys is what makes them reachable.

The supported ways an order reaches a terminal status are: the trader cancels it from the trade screen, KuCoin fills or cancels it and the reconciler picks that up, or you cancel it in KuCoin's own interface and let the reconciler observe the result. All three end with the hold released correctly.

The KPI band lies about one number, on purpose

The analytics strip above the table reports Notional traded as a plain number rather than a currency, and that is deliberate. It sums cost over CLOSED orders, and cost is denominated in whichever asset is the quote half of symbol — there is no quote-currency column to price it from. A book that settled 50,000 USDT and 2 BTC would have printed "$50,002" for roughly $190,000 of flow.

Fee revenue is the exception and is a real USD figure: feeCurrency is a genuine per-row column, so fees are grouped by asset and priced. Effective take rate divides the two, which means its absolute value is meaningless — read it for movement, not as a rate to quote anyone.

The withdrawal queue

Admin → Finance → Withdraw → Log (/admin/finance/withdraw/log).

The queue opens filtered to status = PENDING, sorted oldest first, with an age column that turns colour against the withdrawal SLA. Permissions are access.withdraw / view.withdraw / delete.withdraw; the decision itself runs under edit.wallet and the bulk route under edit.withdraw.

Rows only reach this queue when manual approval is on. That is the default: the route reads withdrawAutoApprove, falls back to the legacy withdrawApproval, and defaults to manual when neither is set.

What the row already tells you

Check these before you decide, because the approval handler will not check them for you:

  • Destination — the address and memo, lifted out of the transaction metadata onto the row itself.
  • Wallet — shown as CURRENCY (TYPE). The type matters: only SPOT pays out through KuCoin.
  • Age — how long the customer has been waiting.
  • Amount — what leaves the wallet. The customer was already debited when they submitted the request; approving does not debit them again.

Approving: what actually happens

Approves and executes one SPOT withdrawal against the active exchange. Refuses ECO. Routes FIAT to bookkeeping.

{id} is the transaction id, not a wallet id. The handler runs in this order, and the order is what makes a failure recoverable:

  1. Route on wallet type. An ECO wallet is refused outright with "Ecosystem withdrawals are settled on-chain by the ecosystem queue and cannot be approved here." A FIAT wallet goes to the bookkeeping path — status flips to COMPLETED and the platform fee is booked, because you already paid by bank. Everything below is the SPOT path.

  2. Refuse anything not PENDING. 400, "Transaction is not pending".

  3. Re-check the balance. The withdraw amount plus a chain fee must not exceed the customer's wallet.balance. In practice that fee is always 0 on this path: the handler looks it up as currencyData.chains[…].withdrawFee, and exchange_currency has no chains column — its columns are currency, name, precision, price, fee and status. So the amount sent to KuCoin is the amount on the request, and any chain fee is KuCoin's to deduct.

  4. Claim the row. UPDATE transaction SET status = 'PROCESSING' WHERE id = ? AND status = 'PENDING'. If that matches zero rows, the request loses and gets 409 — "Withdrawal is already being processed". This is the guard that makes a double-click, a bulk run overlapping a manual click, or a retry incapable of broadcasting the same payout twice. A 409 here is not an error to retry; it means someone or something else already has the row.

  5. Transfer main → trade on KuCoin. exchange.transfer(currency, amount, "main", "trade"). The handler only continues if the response carries an id. A failure here throws "Transfer failed: …" and logs under WALLET.

  6. Withdraw, passing the address, the memo, the chain translated through the chain-id map (BEP20 → bsc, ERC20 → eth, TRC20 → trx, BEP2 → bnb) and a client order id of wd_<transaction id>.

  7. Resolve the outcome by re-reading it. fetchWithdrawals(currency) is called and the response is searched for the id KuCoin just returned. Only if that search finds a record is a status assigned — completed/ok to COMPLETED, cancelled/canceled to CANCELLED, failed to FAILED, anything else to PENDING — and only then is the real fee read.

Two outcomes of step 7 need naming, because neither is what the row appears to say.

The withdrawal is not in the list yet. fetchWithdrawals is a separate call made moments after withdraw returned an id. If KuCoin has not surfaced the record yet, or that call throws, no status is assigned — and the handler's failure test treats an unset status exactly like a rejection. It refunds the customer under withdraw_approve_refund_<id>, marks the row REJECTED with refunded: true, and returns a 500. KuCoin, meanwhile, has accepted the send.

So a REJECTED, refunded row is not proof that nothing left. Before you accept one at face value, search KuCoin's withdrawal history for the client order id wd_<transaction id>. If it is there, the customer has been paid twice and you need to correct their wallet with Adjust Balance.

The withdrawal is found but still processing. The status becomes PENDING and the row is written back with a referenceId — landing in the queue's default filter again with the Approve button live. The atomic claim in step 4 does not protect you: the row genuinely is PENDING, so a second approval passes every guard and runs a second main → trade transfer and a second withdraw.

referenceId is what tells these apart. A PENDING withdrawal that has a reference has already been sent — leave it alone. processPendingWithdrawals sweeps exactly those rows every 30 minutes: it requires a non-null referenceId, re-reads the withdrawal from KuCoin and writes the final status. It never re-sends.

Binance and OKX go straight from claim to withdraw. KuCoin does not. Funds sitting in the wrong KuCoin account — everything swept into Trade, nothing in Main — abort the payout at a stage that has no equivalent on any other provider, with an error that says nothing about accounts.

It is also the step that needs KuCoin's Transfer permission on the API key. See API keys and permissions. "Transfer failed" is a funding or a key-permission problem, never a network problem.

When the exchange says no

If there is no withdraw response, no id, no resolved status at all, or the resolved status is FAILED or CANCELLED, the handler:

  1. Credits the customer back the full amount plus fee, under the idempotency key withdraw_approve_refund_<transaction id>, operation type REFUND_WITHDRAWAL.
  2. Sets the transaction to REJECTED and writes failureReason, refunded: true and refundedAt into its metadata.
  3. Throws 500 — "Withdrawal failed: <reason>. User has been refunded."

The customer is whole and the metadata records why. But "the exchange said no" and "the exchange said nothing in time" reach this branch identically, so always check wd_<transaction id> against KuCoin's withdrawal history before you treat a refunded row as a payout that never happened. And this is never a transient failure to retry — fix the cause first.

Status codes are preserved rather than flattened to 500, which is what lets you tell the four refusals apart:

Code Meaning Retry?
400 Not pending, insufficient customer balance, or an ECO wallet No
404 Transaction, wallet or currency row missing No
409 Another request holds the claim No — go and find it
500 Transfer failed, withdraw failed, or the exchange rejected it (customer refunded) Only after fixing the cause

Rejecting

Rejects a withdrawal and refunds the customer. A reason is required and is emailed to them.

Reject accepts PENDING, PROCESSING and TIMEOUT — deliberately wider than approve. That is what un-freezes a payout stranded mid-flight, which otherwise has no recourse anywhere in the panel. It refunds SPOT with a standard credit and ECO through ecoRefund, then emails the customer your reason.

The reason field is not optional and not decorative: it is stored on the transaction as metadata.note and sent to the customer verbatim. The dialog enforces at least three characters.

Bulk decisions

The bulk menu and the row buttons are built from the same config, so they can never disagree about which statuses are decidable — PENDING and PROCESSING.

Bulk approve or reject. Delegates each id to the single-row handler.

It does not reimplement anything: each id is handed to the same approve or reject handler, so the wallet-type routing, the atomic claim, the KuCoin transfer, the refund-on-failure and the customer email all come along unchanged. Two consequences to expect:

  • Partial success is reported, not rolled back. Approving forty payouts is forty independent money movements. The response gives succeeded, failed and a failures array naming each id and its error. Rows already COMPLETED come back as "Already COMPLETED" rather than being paid twice.
  • If nothing succeeded, it raises. A batch where every row failed returns 400 with the first failure quoted, so a green toast never covers a batch that did nothing.

One reason is shared across a bulk rejection, and every one of those customers receives it.

Every row does its own main → trade transfer, its own withdraw and its own fetchWithdrawals. A large batch is a burst of signed KuCoin calls, and the reliable way to earn the rate-limit ban that silences the whole spot stack. Work large queues in batches, and check the Main-account float first.

The tickets these two screens produce

Admin → CRM → Support (/admin/crm/support) is a chromeless three-pane console; the searchable archive with bulk verbs is /admin/crm/support/tickets. Both are gated on access.support.ticket. The mechanics of the desk — the queue ordering, the four statuses, assignment — are covered in Running the support desk. What follows is only the spot-specific part: what to look up before you answer.

Customers rarely see KuCoin's own words. The user-facing withdrawal failure message is fixed text — "Withdrawal request failed. Please try again or contact support." — and the deposit-address path runs its errors through a sanitiser that replaces kucoin, binance and okx with *** before they leave the server. Every real reason is on your side of the wall, which is why these tickets arrive with no information in them.

"My withdrawal is stuck / never arrived." Open the row on /admin/finance/withdraw/log.

  • PENDING with no referenceId — it is waiting for you. Nothing has been sent.
  • PENDING with a referenceId — KuCoin has it and is still processing. Do not approve it again; the 30-minute sweep will finish it.
  • PROCESSING with a null referenceId — the customer was debited but KuCoin was never asked to send. reconcileSpotWithdrawals sweeps for exactly this every 5 minutes and will either confirm or refund it. If it has not, the connection is down or banned.
  • PROCESSING or COMPLETED with a referenceId — it left. Search KuCoin's own withdrawal history for the client order id wd_<transaction id>; that is the fastest way to answer "did this actually leave" without touching the platform's records.
  • REJECTED with refunded: true in metadata — the customer has their money back and failureReason says why. Verify it against KuCoin anyway: a withdrawal KuCoin accepted but had not yet listed reaches this same branch, so a refunded row can coexist with a real send. wd_<transaction id> in KuCoin's withdrawal history settles it.

"My deposit has not been credited." The live path polls every 15 seconds for 30 minutes while the deposit screen is open; processSpotPendingDeposits sweeps every 15 minutes for everything else. A deposit marked TIMEOUT was refused on age by depositExpiration, not lost. See Deposits and withdrawals.

"My order is still open / my balance is locked." Find the row on /admin/finance/order/exchange and read referenceId, then check the SPOT_RECON log lines above. Do not delete the order row — that makes the lock permanent. If the market row for the symbol has been removed, restoring or recreating it is what lets the reconciler finish.

Cross-check the money, not the ticket. /admin/finance/wallet has an Adjust Balance row action (ADD / SUBTRACT, with an optional customer notification). It moves balance only — it does not touch inOrder, so it cannot release a stuck order hold. Use it to correct a ledger you have already reconciled by hand, never as a substitute for letting a settlement path finish.

What we could not determine

  • No screen in the product shows the KuCoin Main account balance. The only balance surface, /admin/finance/exchange/balance, resolves to Trade. Main has to be read in KuCoin's own interface.
  • Nothing records which provider an exchangeOrder or transaction row was executed against. There is no column and no metadata key for it.
  • No screen compares your customers' aggregate SPOT liabilities against the exchange account. The balance screen is the account; the sum of wallet.balance + wallet.inOrder per currency is the liability, and reading it means querying the database.