Switching the active exchange provider

Moving spot off KuCoin onto Binance or XT, or onto KuCoin from another provider — the restart that actually applies it, the passphrase asymmetry, and the float and orders nobody moves for you.

17 min readUpdated 6 August 2026migration, provider-switch, passphrase, float, restart

Exactly one row in the exchange table may have status = true, and the admin panel makes changing it a single switch. That switch is honest about one thing only: which row carries the flag. The running connection, the imported markets and currencies, open orders, pending deposits, the candle cache and — most importantly — the coins themselves are all unaffected and all still point at the exchange you just left.

Your customers' SPOT balances are ledger rows. The assets backing them sit in your KuCoin account. Flipping the provider does not move an asset, does not close an order, does not retire a deposit address and does not tell you the shortfall. Read this page end to end before you touch the toggle, and do it with trading paused.

What the switch actually does

The control lives on Admin → System → Extensions, filtered to exchanges (/admin/system/extension?type=exchange) — not on the exchange hub.

Enables or disables a provider. Refuses with 403 and licenseRequired when no licence file exists.

Sending status: true does three things and nothing else:

  1. Reads lic/<productId>.lic from disk for the row being enabled. No file, 403 with licenseRequired: true, nothing written. KuCoin's product id is 37179816.
  2. Opens a transaction and runs UPDATE exchange SET status = false for every other row.
  3. Sets status = true on this one and commits.

It never touches exchange_market, exchange_currency, exchange_order, wallet, the chart cache or the running connection, and it notifies no process that anything changed.

One route in the codebase would. POST /api/admin/finance/exchange/provider/{productId}/activate calls saveLicense(productId, envatoUsername), which sets status: false on every other provider and status: true on the one just licensed, alongside licenseStatus and the Envato username. No admin screen calls it. It is orphaned.

What the panel actually does: the Activate License buttons on an exchange's Extensions page send you to Admin → System → License (/admin/system/license?productId=…), which posts to /api/admin/system/license/activate. That writes lic/<productId>.lic and refreshes the licence caches in the process that served the request. It never opens the exchange table, so the active row stays exactly where it was.

So licensing a Binance or XT product "just to have it ready" is safe — KuCoin keeps serving. The status toggle above is the only control that moves the flag.

The trap: the KuCoin connection is memoised for the life of the process

ExchangeManager is a process-lifetime singleton. Two private fields survive the switch:

Field Holds Cleared by
provider the provider name read once from the exchange table removeExchange(name), and only when name is the one this process is holding
exchange the live ccxt instance built for that name removeExchange(name) under the same condition, or stopExchange()

startExchange() — the call every spot path makes to get a connection — returns this.exchange before it looks at anything else. There is no timestamp, no version check, no invalidation hook. A process that built a KuCoin client before you flipped the switch keeps handing that same KuCoin client to order placement, the ticker poller, the deposit verifier, the withdrawal approver and the admin finance screens for as long as it lives.

Three follow-on details:

  • stopExchange() is not enough. The ticker and market WebSocket services call it when the exchange errors. It closes and evicts the instance but leaves provider set, so the next startExchange() rebuilds a connection to the old exchange under the old name.
  • The one eviction that works is per-process and conditional. Saving the provider row (PUT /api/admin/finance/exchange/provider/{productId} — the proxy field on the exchange hub's Settings tab) calls removeExchange(name). That always drops name from the instance cache, but it nulls provider and exchange only if (this.provider === provider). Saving the row for the provider you have just switched to fails that guard; it is saving the row for KuCoin, the provider the process is still memoising, that clears both. And either way it only runs inside the process that served that HTTP request.
  • backend and cron are separate processes. production.config.js starts backend with CRON_MODE=off and cron with CRON_MODE=only. Each builds its own connection. Both must be restarted.

Why Verify Credentials tells you everything is fine

Opening Admin → Finance → Exchange (/admin/finance/exchange) runs the active-provider check, and the Verify Credentials button runs the same routine on demand.

Returns the enabled provider, re-syncs its licence flag from disk, and runs a live credential check.
Round-trips the saved credentials against the exchange using a throwaway connection.

Both build a brand-new, throwaway ccxt instance from APP_<PROVIDER>_API_KEY, _API_SECRET and _API_PASSPHRASE, load markets, call fetchBalance and close it. They never look at the cached instance and never replace it.

That produces the single most misleading state this product can be in: the exchange hub reports the new provider, licensed, "API credentials are valid and connection successful" — while every customer order in the same minute is still being placed on KuCoin. A green verify proves your .env is right. It proves nothing about what the running processes are doing.

The passphrase asymmetry

KuCoin authenticates with three values. Binance and XT authenticate with two. That is not symmetric, and the two directions fail differently.

KuCoin API passphrase, passed to ccxt as password. Required by ccxt for KuCoin; has no counterpart on Binance or XT.

Switching away from KuCoin. APP_KUCOIN_API_PASSPHRASE becomes inert. Nothing reads it, because the variable names are built at runtime as APP_${provider.toUpperCase()}_API_* from the provider's name column. Leaving it in .env is harmless; deleting it is harmless. Keep it until the migration is finished — you may need the KuCoin key to move the float, and re-creating a passphrase is impossible (KuCoin never shows it again).

Switching to KuCoin. Forgetting it is the most expensive mistake on this page, because the platform's own credential guard checks only the key and the secret. What happens next depends on which way it is wrong:

State What happens
Absent or empty ccxt raises before any network call — kucoin requires "password" credential. The manager catches it, retries three times five seconds apart, then returns nothing and enters a 30-minute cooldown. Each caller then words it differently: order placement answers 503 Exchange service is currently unavailable, a customer cancel 503 Service currently unavailable, the balance screen 500 Exchange or provider not available, and only the two admin imports say "Failed to start exchange provider: kucoin". The crons say nothing to a screen at all — they log "Exchange unavailable; skipping…" and no-op
Present but wrong The client is built and market loading fails on the signed call. The manager then replaces it with an unauthenticated instance and logs "Falling back to an unauthenticated kucoin instance…". Markets, tickers and charts keep working. Balances, deposit addresses and withdrawals do not
Correct but added after the restart Credentials are read once, when the client is first built. Nothing changes until you restart

The middle row is the dangerous one: nothing on screen says anything is wrong, and the Platform Health panel on the admin dashboard (/admin — there is no Health screen under System) reports "kucoin configured" because its exchange check reads only APP_KUCOIN_API_KEY and APP_KUCOIN_API_SECRET and never the passphrase. Troubleshooting has the grep that separates the three.

What the switch leaves behind

Nothing in this table is touched, migrated or flagged.

What State after the switch Consequence
exchange_market rows unchanged — symbols and metadata.precision, .limits, .maker, .taker orders are validated and priced against KuCoin's rules on a different exchange
exchange_currency rows unchanged — currency, name, precision, fee, status assets KuCoin listed and the new provider does not stay listed
Deposit and withdrawal networks read live from the active provider per request, never stored this half follows the switch on its own, and immediately disagrees with the currency rows
exchange_order rows with status = OPEN unchanged, still holding wallet.inOrder see below — these become unsettleable
wallet rows of type SPOT unchanged, balance and inOrder intact the ledger still says the customer owns coins; the coins are at KuCoin
transaction rows of type DEPOSIT, status PENDING unchanged the verifier now polls the wrong exchange for the customer's txid
Chart cache data/chart/<base>/<quote>/<interval>.json.gz and Redis ohlcv:<symbol>:<interval> — no provider in either path KuCoin candles are served as the new exchange's history
exchange:ban_status in Redis one global key, not per provider a rate-limit ban earned on KuCoin still mutes the new provider until its TTL expires

Deposit addresses stay live, and stop being credited

This one has no row in the table above because there is no row anywhere. Spot deposit addresses are not stored. They are fetched from the active exchange per request, through a five-method cascade, and handed to the customer.

Every address your platform has ever shown a customer is a KuCoin address. It does not expire when you switch. Coins sent to it still arrive — in your KuCoin account — and the platform will never credit them, because both the live verifier and processSpotPendingDeposits call fetchDeposits on the active exchange, and the new exchange has never heard of that deposit.

Customers bookmark these. Some wallets and exchanges save them as address-book entries. Expect deposits to a retired KuCoin address for months, and plan to sweep them by hand:

  1. Keep the KuCoin account and its API key open after the switch.
  2. Watch KuCoin's own deposit history for arrivals.
  3. Credit each one with Adjust Balance on /admin/finance/wallet — the ADD direction, with a description that names the txid — and move the coins to the new exchange.

Before you switch: settle the book

Do this while KuCoin is still active. Most of it is impossible afterwards.

/admin/finance/order/exchange is read-only: canCreate, canEdit and canDelete are all false. Adjust Balance on /admin/finance/wallet moves balance by ADD or SUBTRACT and does not touch inOrder.

Two code paths move money out of inOrder and back, and neither is an admin action: the customer's own cancel, and the reconciliation cron, which releases the hold when the exchange reports the order CANCELED, EXPIRED or REJECTED or reports a partial fill. Both have to resolve the order on the active exchange first, which is exactly what stops working after the switch. See The spot desk.

  1. Stop new orders as far as the platform lets you. Set status = false on every row in Admin → Finance → Exchange → Market (/admin/finance/exchange/market). A disabled market drops out of /api/exchange/market and out of the ticker stream, so it disappears from the trade screen.

    That is presentation, not enforcement. The spot order-create route looks the market up by currency and pair and refuses only when the row or its metadata is missing — it never reads market.status. The one server-side refusal is the global spotStatus setting, and no admin screen in this build writes it. So watch the OPEN order count in step 2 rather than assuming the book is frozen.

  2. Clear every OPEN order. Filter /admin/finance/order/exchange on status = OPEN. Each of those rows must reach a terminal status before the switch — the customer cancels it, or it fills. The referenceId column is the order id at KuCoin; once the connection points elsewhere, that id resolves to nothing.

  3. Finish pending spot deposits. A DEPOSIT transaction at PENDING is waiting for the verifier to match the customer's txid against fetchDeposits on the active exchange. Coins that arrived at a KuCoin address will never appear in Binance's deposit list.

  4. Clear the withdrawal queue. Approve or reject everything on /admin/finance/withdraw/log before the payout path changes underneath it. Remember that a KuCoin approval also runs a main → trade transfer, so leave enough float in the Main account to drain the queue.

  5. Move the float. Covered below. It is the long pole.

Making the switch

  1. Activate the new provider's licence on Admin → System → Extensions → Exchanges, which hands you to Admin → System → License to enter the purchase code. This writes lic/<productId>.lic and nothing else — the active row does not move, so KuCoin is still the enabled provider. Do it first anyway: the toggle in step 3 refuses with 403 and licenseRequired until that file exists.

  2. Put the new credentials in the project root .env. The names are built at runtime from the provider's name column, uppercased:

    APP_BINANCE_API_KEY="..."
    APP_BINANCE_API_SECRET="..."

    Leave the APP_KUCOIN_* variables in place, passphrase included. They are what let you move the float and sweep late deposits.

  3. Flip the toggle. On Admin → System → Extensions → Exchanges, enable the new provider. This is the step that runs the transaction, and it is the only one that does: from this moment the database says the new provider is live even though nothing is connected to it. Confirm KuCoin now reads inactive.

  4. Restart both processes. This is the step that actually switches the platform. Nothing before it changed a single running connection.

    pm2 restart backend cron
  5. Verify credentials on /admin/finance/exchange. Now — and only now — a green result describes the connection your customers are using.

  6. Update NEXT_PUBLIC_EXCHANGE and rebuild the frontend. It is compiled in, not read at runtime. "kuc" selects KuCoin's order-book depth ladder and chart provider; "bin" is Binance's. Leaving it wrong makes the browser ask for depths the new exchange will not serve.

After the restart: the two imports

Both are preview-first — the first press computes a plan and writes nothing, and confirm=true applies it. Read the delete counts. On a provider switch they will be large, and they describe real rows.

1. Spot currencies, first

Admin → Finance → Currency → Spot (/admin/finance/currency/spot), then Import.

Reads the active provider's currency catalogue. Without confirm=true it returns the plan only.

Currencies the new provider does not list are deleted outright. The exchange_currency row goes; the customer's SPOT wallet row for that currency does not, and their balance stays on it. What breaks is every path that looks the currency up — the spot withdrawal route answers 404 Currency not found before it will do anything.

Check the preview's deleteSample against what your customers actually hold before confirming.

The import creates new currencies with status: false, refreshes name, precision and fee on ones that already exist, and deliberately leaves status alone. A confirmed import then kicks the price cron.

Afterwards, act on the missing currencies alert at the top of the screen — a pair whose base or quote currency is not enabled will not trade, and a provider switch is the most reliable way to create a batch of them.

2. Markets

Admin → Finance → Exchange → Market (/admin/finance/exchange/market), then Import.

Reads the active provider's market list. Delisted markets that still carry OPEN orders are never removed.

The import only creates rows for symbols it does not already have. For a symbol already in exchange_market it writes nothing at all — no precision, no limits, no maker or taker rate.

BTC/USDT exists on every provider, so it survives untouched and keeps KuCoin's decimal places, minimum cost and fee rates forever. Those are the numbers your platform validates customer orders against before forwarding them, so the first symptom is orders the new exchange rejects for a precision or minimum-notional violation your platform thought was fine.

To genuinely refresh a market, delete the row and let the import recreate it, or edit its metadata by hand. Both are enabled on the markets screen.

The import refuses to remove a delisted market that still carries OPEN orders and reports the count as keptForOpenOrders. Those rows survive the switch on purpose — each open order is holding inOrder against a customer's balance.

The per-row delete button has no such guard. With the market row gone the reconciler cannot resolve the fee rate, logs "Market data not found for <symbol>" under SPOT_RECON and abandons the order permanently, hold included.

So after a switch you will have a residue of KuCoin-era markets the import would not remove. They are not junk — they are the markets your unsettleable orders live on. Settle those orders from /admin/finance/order/exchange first, then re-import to clear the markets.

Newly imported markets arrive with status: false. Nothing is visible to a customer until you enable it — and enabling needs edit.ecosystem.market, not edit.exchange.market. See Currencies, markets and charts.

3. Rebuild the chart cache

Candles carry no provider in their path, so KuCoin's history is still being served as the new exchange's. Admin → Finance → Exchange → Chart (/admin/finance/exchange/chart) has a Clean action.

Deletes cached candles from Redis and from disk, per symbol and interval.

Clean the pairs you carry, then rebuild. Do not lower the rateLimit in chart_cache to speed a large build up — that is the reliable way to earn the ban marker that silences the whole spot stack.

Orders that survive the switch

If any order was still OPEN when the connection changed, this is what happens to it, and it is worth understanding because the failure is silent.

processPendingSpotOrders runs every 60 seconds, sweeps every exchangeOrder row with status = OPEN and a non-null referenceId, and calls fetchOrder(referenceId, symbol) on the currently active exchange. That id was minted by KuCoin. The new exchange answers with an error, the cron catches it, logs Failed to reconcile spot order <id> (ref <referenceId>) under SPOT_RECON, and moves on. It repeats every tick, forever. The customer's own cancel does the same lookup and fails the same way.

There is no supported recovery. Nothing in the admin panel releases the hold, because releasing it correctly means deciding whether the order filled — and the exchange that knows is no longer the one the platform talks to. The honest options are:

  • Switch back to KuCoin temporarily, restart, and let the orders settle. This is the only clean one.
  • Reconcile each customer by hand against KuCoin's own trade history and correct their wallet rows with Adjust Balance, accepting that the inOrder hold will still be there.

That is why step 2 of the pre-switch checklist is not optional.

Payouts branch on the provider name

The withdrawal approval path chooses its branch from the resolved provider name, and the branches are not equivalent:

Provider What approval does
kucoin an internal transfer from main to trade first, then withdraw with { chain }, then re-reads the withdrawal for the real fee
binance, okx withdraw(currency, amount, address, memo, { network: chain }), then maps the returned status
anything else nothing

The default: branch is empty. With no withdraw response the handler treats the payout as rejected and credits the customer back — so on a provider with no branch, every SPOT withdrawal approval fails and refunds, cleanly but permanently, until code exists for it.

xt is one of the three seeded providers and is not in that switch. If you are moving to XT, verify a small withdrawal end to end before you tell customers payouts are open.

The customer-initiated withdrawal route (used when auto-approval is on) has a wider set — kucoin, binance, kraken, okx and xt — and refuses anything else with "Withdrawal method not currently available." The two lists disagree, so test both paths on whatever you switch to.

The float

This is the part no screen performs.

Every SPOT balance on your platform is a claim against one exchange account. After the switch the platform pays withdrawals from the new account and credits deposits landing in the new account, while the assets backing your existing ledger are still at KuCoin.

The live balance of the active exchange account, per asset, split into available and in-order.

/admin/finance/exchange/balance calls fetchBalance on whichever exchange is active. It is the real account, and it is not compared to anything — no screen sums your customers' SPOT wallet.balance per currency and puts the two side by side.

ccxt maps its default account type to KuCoin's trade account, so the figures you have been reading on that screen were never your whole KuCoin holding. Anything parked in Main — including everything the withdrawal path's main → trade transfers left behind — was invisible there.

When you total what to move off KuCoin, read both accounts in KuCoin's own interface. The platform will under-report.

Moving the float is a manual, per-asset job:

  1. Total what you owe per currency: the sum of balance plus inOrder across wallet rows where type = 'SPOT', grouped by currency. There is no report for it; read it from the database.
  2. Withdraw each asset from KuCoin to the new exchange, from the exchanges' own interfaces, with their own networks and fees. The platform gives you nothing to do this with — there is no admin-initiated withdrawal. The only routes under /api/admin/finance/wallet/{id}/withdraw are approve and reject, {id} is a transaction id, and approve simply executes a customer's already-pending request. Approving one to shift float would send a customer's money, not yours.
  3. Keep the KuCoin key and its .env variables until every asset has landed. Transfers take confirmations, and some assets will need a network the new exchange does not support, which means selling and re-buying.
  4. Only then compare the new account's balance screen against the totals from step 1, currency by currency.

Between the switch and the float landing, your customers' ledger says they own coins the active exchange does not hold. Nothing warns you. The shortfall surfaces as the first approved withdrawal the exchange refuses for insufficient balance — which the platform then refunds to the customer, leaving the request looking like a transient failure rather than an empty account.

Keep withdrawals closed until the float is in place.

Switching back to KuCoin

The same procedure in reverse, with the same restart, plus three things specific to this direction:

  • Set all three APP_KUCOIN_* variables, passphrase included, before the restart. See the asymmetry section above for what each way of getting it wrong looks like.
  • Expect the market import to drop contract symbols. ccxt loads KuCoin's spot, swap and futures markets together; anything whose spot flag is not true and any symbol containing a colon (BTC/USDT:USDT) is skipped on purpose. One contract symbol in exchange_market breaks multi-symbol calls with "kucoin symbols must be of the same type" and takes the whole ticker batch with it.
  • Fund the Main account. Withdrawals transfer main → trade before they send. A float that lives entirely in Trade fails every payout at step one.

There is no snapshot of the previous market and currency state to restore. The rows are now the other provider's, and the same staleness applies going back.

What we could not determine

  • Nothing records which provider an exchange_order, wallet or transaction row belongs to. There is no column, no metadata key and no audit entry saying "this order was placed on KuCoin". After a switch the only way to tell is the row's date against the date you switched.
  • The exchange table's version and type columns are seeded and displayed but play no part in the switch.
  • There is no export or archive of the pre-switch exchange_market and exchange_currency rows. If you want them, take a database backup before you import.