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.
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).
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>)— theexchange_marketrow 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: onlySPOTpays 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
{id} is the transaction id, not a wallet id. The handler runs in this
order, and the order is what makes a failure recoverable:
-
Route on wallet type. An
ECOwallet is refused outright with "Ecosystem withdrawals are settled on-chain by the ecosystem queue and cannot be approved here." AFIATwallet goes to the bookkeeping path — status flips toCOMPLETEDand the platform fee is booked, because you already paid by bank. Everything below is the SPOT path. -
Refuse anything not PENDING. 400, "Transaction is not pending".
-
Re-check the balance. The withdraw amount plus a chain fee must not exceed the customer's
wallet.balance. In practice that fee is always0on this path: the handler looks it up ascurrencyData.chains[…].withdrawFee, andexchange_currencyhas nochainscolumn — its columns arecurrency,name,precision,price,feeandstatus. So the amount sent to KuCoin is the amount on the request, and any chain fee is KuCoin's to deduct. -
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. -
Transfer
main → tradeon KuCoin.exchange.transfer(currency, amount, "main", "trade"). The handler only continues if the response carries anid. A failure here throws "Transfer failed: …" and logs underWALLET. -
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 ofwd_<transaction id>. -
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/oktoCOMPLETED,cancelled/canceledtoCANCELLED,failedtoFAILED, anything else toPENDING— 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:
- Credits the customer back the full amount plus fee, under the idempotency key
withdraw_approve_refund_<transaction id>, operation typeREFUND_WITHDRAWAL. - Sets the transaction to
REJECTEDand writesfailureReason,refunded: trueandrefundedAtinto its metadata. - 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
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.
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,failedand afailuresarray naming each id and its error. Rows alreadyCOMPLETEDcome 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.
PENDINGwith noreferenceId— it is waiting for you. Nothing has been sent.PENDINGwith areferenceId— KuCoin has it and is still processing. Do not approve it again; the 30-minute sweep will finish it.PROCESSINGwith a nullreferenceId— the customer was debited but KuCoin was never asked to send.reconcileSpotWithdrawalssweeps for exactly this every 5 minutes and will either confirm or refund it. If it has not, the connection is down or banned.PROCESSINGorCOMPLETEDwith areferenceId— it left. Search KuCoin's own withdrawal history for the client order idwd_<transaction id>; that is the fastest way to answer "did this actually leave" without touching the platform's records.REJECTEDwithrefunded: truein metadata — the customer has their money back andfailureReasonsays 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
exchangeOrderortransactionrow 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.inOrderper currency is the liability, and reading it means querying the database.
Related
- Deposits and withdrawals — the money paths in full, including the fee settings and the reconciliation crons
- API keys and permissions — which KuCoin permission each action on this page needs
- Switching providers — what happens to the rows on these screens when the active provider changes
- Reference — every endpoint, permission and cron named here
- Troubleshooting — the ban marker, the cooldown and the passphrase