Permissions, background jobs and Redis keys
Every permission key the Binance spot surface checks, the five cron jobs that keep the ledger reconciled with their real periods, and the Redis keys that cache or mute it.
Three things decide whether this integration works for somebody other than you: which permission keys their role holds, whether the cron process is running, and whether a Redis key is quietly telling every spot path to do nothing. All three are listed here.
The connection itself — clock sync, instance caching, rate limits, the proxy — is in Connection, rate limits and bans.
Permissions
Two different layers read these keys and they read them for different reasons.
access.*keys decide what an operator can see.frontend/config/menu.tshides a menu entry whosepermissionthe role does not hold, and the admin data tables refuse to render without theiraccesskey. No backend route is gated on anaccess.*key.view.*/create.*/edit.*/delete.*/manage.*keys are what the API enforces. A missing one answers403 Forbidden - You do not have permission to access this.
Super Admin bypasses every check. The role gate short-circuits on
userRole.name === "Super Admin", so a permission problem is invisible while you
are testing as the owner account. Test role changes on a real staff account.
The exchange surface
| Permission | What it gates |
|---|---|
access.exchange |
The Trading Infrastructure → Exchange Providers menu entry and /admin/finance/exchange |
view.exchange |
Reading the provider list, and the active-provider check the exchange hub runs on load |
edit.exchange |
Enable/disable a provider, activate a licence, save or test the proxy, Verify Credentials |
access.exchange.market |
The markets table |
view.exchange.market |
Listing markets, and reading one |
create.exchange.market |
Import Markets |
edit.exchange.market |
Editing a market's precision, limits and fees |
delete.exchange.market |
Removing a market row |
edit.ecosystem.market |
Enabling or disabling a market — see the trap below |
access.spot.currency |
The spot currency table |
view.spot.currency |
Listing currencies, and the missing currencies check |
create.spot.currency |
Import on the spot currency screen |
edit.spot.currency |
Editing a currency, and toggling its status |
view.exchange.balance |
/admin/finance/exchange/balance — the live account balances |
view.exchange.fee |
/admin/finance/exchange/fee — the fee comparison |
view.exchange.chart |
/admin/finance/exchange/chart, and the cache settings response |
manage.exchange.chart |
Build, Fix gaps, Clean, and saving cache settings |
access.exchange.order |
The Order Management → Spot Orders menu entry and the table |
view.exchange.order |
Listing spot orders, and opening one |
edit.exchange.order |
Updating an order, and the single and bulk status routes |
delete.exchange.order |
Deleting spot orders, single and bulk |
view.exchange.watchlist |
Reading one watchlist row |
The two routes that flip a market's status live under
/api/admin/finance/exchange/market but are gated on edit.ecosystem.market,
not edit.exchange.market:
The switch in the markets table renders regardless — the toggle cell is not permission-aware — so the operator flips it, it snaps back, and nothing on screen says why. The backend logged a 403.
Grant edit.ecosystem.market to any role that curates the market list, even on
an install that has never had Ecosystem. It is the only key that controls which
pairs your customers can trade.
The money surface
Withdrawals, wallets and the transaction ledger are core screens, not
exchange-provider screens, so their keys are unrelated to exchange.*. An
operator who is meant to handle customer payouts needs these as well.
| Permission | What it gates |
|---|---|
access.withdraw |
The Withdrawal Management → Withdrawal Records menu entry and /admin/finance/withdraw/log |
view.withdraw |
Listing withdrawals and opening one |
edit.withdraw |
The bulk approve/reject route on the withdrawal queue |
delete.withdraw |
Deleting withdrawal rows |
edit.wallet |
The single-row approve and reject routes — the ones that actually pay |
access.wallet / view.wallet |
/admin/finance/wallet |
delete.wallet |
Deleting a wallet row |
access.transaction / view.transaction |
/admin/finance/transaction |
access.cron / view.cron |
/admin/system/cron |
manage.cron |
Triggering a job by hand |
Both surfaces end up in the same two handlers, but they arrive by different
routes with different keys. The row buttons and the bulk menu on
/admin/finance/withdraw/log send the queue-decision route; the buttons on
/admin/finance/withdraw/log/{id} call the wallet routes directly:
A role with edit.withdraw but not edit.wallet can decide every row from the
queue and cannot decide a single one from a row's own detail page. The reverse is
also true. Grant both to anyone who handles payouts.
Keys that are seeded but gate nothing
The permission seeder writes these; no route or screen in the Binance surface checks them. Granting them changes nothing, and their absence is not your bug.
| Key | Why it is inert |
|---|---|
create.exchange.order |
The spot order table declares it, but canCreate is false and there is no admin order-create route. Orders are placed by customers |
delete.spot.currency |
There is no delete route under /api/admin/finance/currency/spot. Currencies are removed by the import, not by hand |
The one grant to think twice about
edit.exchange is not "can edit some settings". It is two very consequential
powers behind one key:
- It can switch the whole spot back end to another exchange.
PUT /api/admin/finance/exchange/provider/{id}/statusdisables every other provider in the same transaction. So does the licence activation route. The markets, currencies, open orders and customer balances all stay pointing at Binance — see Switching the active exchange provider for what that costs. - It can route every exchange call through a host of the holder's choosing.
The proxy field on the exchange hub's Settings tab is saved by
PUT /api/admin/finance/exchange/provider/{productId}, which also evicts the cached connection so the next request rebuilds through the proxy. Credentials embedded in the URL are masked on read (//***:***@), so the value cannot be read back out of the panel — but everything the platform sends to Binance, signed with your API key, goes through whatever was saved.
view.exchange is safe to hand out. edit.exchange belongs to the same small
group of people who hold the .env file.
Background jobs
All of these run in the cron process, not the web process. production.config.js
starts backend with CRON_MODE=off and a separate cron app with
CRON_MODE=only; a single-process install sets CRON_MODE=inline instead. If
the cron app is not running, none of the reconciliation below happens and
nothing on any screen says so.
| Job | Period | What it does |
|---|---|---|
processPendingSpotOrders |
60 s | Reconciles OPEN spot orders against Binance — credits fills, refunds cancellations, handles partials |
processCurrenciesPrices |
2 min | Rewrites exchangeCurrency.price for every enabled currency |
reconcileSpotWithdrawals |
5 min | Crash recovery: finalises PROCESSING withdrawals whose send committed but whose status update was lost |
processSpotPendingDeposits |
15 min | Catch-up deposit detection for anything the live WebSocket missed |
processPendingWithdrawals |
30 min | General sweep of pending withdrawals against Binance's withdrawal history |
cacheExchangeCurrencies |
60 min | Warms the Redis exchangeCurrencies blob |
Two of these are load-bearing in ways their names do not suggest.
reconcileSpotWithdrawals is the one that prevents a customer being left
short. It only looks at PROCESSING + WITHDRAW rows older than 5 minutes,
scoped away from Ecosystem rows, and it caps itself: 200 rows per run, at most
2,000 rows scanned while paging past ones already in review backoff. A large
backlog therefore clears over several runs rather than one. Rows older than 24
hours become eligible for an evidence-based refund after a 1,000-record lookback
against Binance's own withdrawal history.
processPendingWithdrawals skips a row with no referenceId. That is
deliberate — a row with no exchange id has not been sent, so this job has nothing
to look up. Those rows are reconcileSpotWithdrawals's job.
cacheExchangeCurrencies, and the cache that is usually cold
The job writes the enabled-currency list to Redis under exchangeCurrencies
with a 120-second TTL, tied to the 120-second price cadence so the cached
price can never be more than one tick behind the row.
The job itself runs hourly. The arithmetic is unavoidable: the key exists for
two minutes out of every sixty, and for the rest of the hour
/api/exchange/currency and /api/exchange/currency/{id} miss and read the
database instead. The endpoints never repopulate the key — they fall back and
return, they do not write. So the cache is a small hourly optimisation, not a
dependency, and a Redis outage does not take the currency endpoints down.
What it does mean: nothing else warms this key. If you flush Redis, the currency endpoints are on the database until the next hourly tick, which is fine, and if you were expecting the cache to absorb load it is not doing that.
processPendingWithdrawals is explicit about it — "Exchange unavailable;
skipping pending withdrawal run" — and the others follow the same shape: they
ask ExchangeManager.startExchange() for a connection, get null, log, and
return. The admin cron screen shows them as completed. See the ban key below.
Watching them
Admin → System → Cron (/admin/system/cron) lists every job with its last
run, last error and next scheduled run, and streams live progress over a
WebSocket.
A job that throws is recorded with lastRunError and a failed status;
processPendingSpotOrders deliberately rethrows so a dead reconciler is
distinguishable from a healthy one on that screen.
Backend log tags worth grepping: EXCHANGE (connection, credentials, clock, ban),
SPOT_RECON (order reconciliation), WITHDRAW and WALLET (payouts),
SPOT_DEPOSIT (the live deposit verifier), CRON (the jobs themselves).
Redis keys
| Key | Written by | TTL | Cleared by |
|---|---|---|---|
exchange:ban_status |
The rate-limit handler, and any error text containing IP banned until |
matches the remaining ban, max 24 h | its own TTL, or a manual DEL |
exchange:tickers |
The ticker WebSocket, once a second | none | overwritten each flush; filtered to enabled markets |
ohlcv:<symbol>:<interval> |
Build charts and Fix gaps, and the public chart read-through path, alongside data/chart/<base>/<quote>/<interval>.json.gz |
24 h from the two admin actions, none from the public path | its own TTL, or the chart Clean action |
exchangeCurrencies |
cacheExchangeCurrencies |
120 s | its own TTL |
ecosystem_token_icon:<currency> |
The public market list, per currency | 1 h | its own TTL |
Note what has no TTL. An ohlcv:* key written by Build charts or Fix
gaps self-expires after 24 hours, so the keys an operator creates from that
screen do clear themselves. The same key written by the public chart
read-through path does not, and the gzipped files behind it never expire at all
— so a miss just reloads the old candles from disk into Redis with no TTL, which
is why candles from a previous provider keep being served after a switch.
Clean on /admin/finance/exchange/chart is what removes both.
exchange:tickers is a single key rewritten in place.
exchange:ban_status is a kill switch
The value is an epoch-millisecond unblock time, stored as a string, with the key's TTL set to the remaining seconds so it clears itself.
It is written in two places:
- The rate-limit handler. A
ccxt.RateLimitExceeded, or Binance code-1003, while the connection is being built records a one-minute ban and sleeps a minute before retrying. - Any error message containing
IP banned until <epoch>. That number is scraped out of the free-form text and used as the expiry.
Both go through the same clamp: a time already in the past, or one that does not parse, is ignored; a time more than 24 hours away is clamped to 24 hours with a warning naming the original value. That guard exists because the number comes from error text — a units mismatch would otherwise park a permanent outage in a key nothing routinely inspects.
startExchange() checks the key first and returns null before it looks at
anything else. Order placement, the ticker stream, the price cron, the deposit
verifier, the withdrawal reconciler, the balance screen, the fee screen and the
chart build all receive nothing, log a warning and return. No screen turns red.
The platform reports itself healthy and stops moving.
The one place it surfaces in the panel is the chart settings response, which reports whether a ban is active and how many seconds remain — and the chart page uses it to disable the Build charts button.
Deleting the key early is what unwedges the stack if you know Binance has lifted the block. Deleting it while Binance is still throttling you simply earns a longer one.
What we could not determine
- There is no admin screen that lists Redis keys or lets you delete one. Clearing
exchange:ban_statusmeans aredis-cli DELon the server. - Nothing records which permission key a 403 was raised for, on the client side.
The response is the generic "Forbidden - You do not have permission to access
this"; identifying the missing key means reading the route's
metadataor the backend log.
Related
- Connection, rate limits and bans — how the ban gets set in the first place, and what the connection caches
- The spot desk — the screens these permissions open
- Float, treasury and reconciling — what the reconciliation jobs cannot do for you