Environment variables
Every variable the platform reads from .env, grouped by subsystem — which ones are required, which ones the code reads but the template never declares, and which ones nothing reads at all.
Configuration lives in one file: .env at the repository root, next to
package.json. .env.example is the template the installer copies when no
.env exists.
The backend loads it before any other module runs, probing four paths in order and stopping at the first that exists:
<cwd>/.env # the repo root — this is the one you edit
<backend>/../.env
<backend>/.env
<cwd>/../.envIf none is found it falls back to the ambient process environment, which is how a container deployment can supply everything without a file at all.
The installer sets chmod 600 .env. Keep it that way — the file holds your
database password, four session-signing secrets, every payment credential, and
the passphrase that unlocks custodial wallet keys.
Editing it safely
pnpm env-manager is a targeted line editor for this file. It replaces one line
at a time, so comments, section headers, ordering and quoting survive; a
round-trip through a .env parser strips all of that.
node scripts/env-manager.mjs get --json
node scripts/env-manager.mjs set APP_TWILIO_AUTH_TOKEN=abc123 --restartEvery write snapshots a timestamped .env.bak and renames a temp file into
place. With --restart it drains the backend, restarts, health-checks it, and
rolls back to the snapshot if the process does not come back healthy.
Secret-looking keys are redacted on read, so get reports set/unset rather than
values.
The tool refuses to edit ENCRYPTED_ENCRYPTION_KEY and
ENCRYPTION_KEY_PASSPHRASE at all. Changing either permanently bricks every
encrypted wallet on the install.
Two rules that decide whether an edit takes effect
Anything named NEXT_PUBLIC_* is inlined by Next.js at build time. Editing it
and restarting changes nothing in the browser.
NEXT_PUBLIC_SITE_URL is the worst case: every client API call falls back to it
(frontend/lib/api.ts), and its hostname is baked into images.remotePatterns
in next.config.js. Move the platform to a new domain without running
pnpm build:frontend and the browser keeps calling the old origin while
next/image rejects every image served from the new one.
Everything else is read when a process starts. pnpm restart picks it up —
pnpm stop && pnpm start, which parks the site on the maintenance server in
between.
Application
next/image will optimise. Changing it needs a frontend rebuild.Bicrypto in the PM2 config and most components, My App in the root layout's page titles, App in the PWA manifest — so set it explicitly rather than relying on any of them.true, EVERY new registration is given the Admin role — email/password and Google signup alike, on production builds too. Demo mode also blocks admin writes for anyone who is not Super Admin, and narrows scheduler refusal alerts to Super Admins so a refused cron job does not email every visitor. Leave it false on a real deployment.production on a live install. It is what makes session cookies Secure + SameSite=None, so a production build served over plain HTTP cannot log anyone in. It also drops localhost origins from the CORS allowlist.frontend PM2 app hardcodes PORT: 3000 in its own env block, and a PM2 env block beats the process environment, so editing this does not move the frontend.PORT is ignored — this is the only variable that moves it. The cron app deliberately sits on 4001; nothing should connect there.pnpm start:thread). Clamped to the CPU count. Read by backend/thread.ts and production.thread.config.js; no application code reads it.dark, light or system.frontend/next.config.js; has no effect on a production build.inline means one process both serves HTTP and runs the scheduler; off registers no jobs; only runs jobs and serves no traffic. production.config.js sets off on the backend app and only on the cron app, so the split is already whole. Set CRON_MODE="inline" in .env to collapse back to one process — production.config.js reads it and drops the cron app entirely.1, true, yes or on (trimmed, case-insensitive) and production.config.js adds a fourth PM2 app, trading, after backend: the same entry point with CRON_MODE=off and ECO_PROCESS_ROLE=trading on ECO_TRADING_PORT, which hosts the Ecosystem matching engine, the AI market maker and the trading bots and serves the order, market, ticker and Hummingbot routes. The web app is marked ECO_PROCESS_ROLE=web and never holds the engine again. The trading app is given DB_POOL_MAX=40, WALLET_TX_CONCURRENCY=16 and SCYLLA_LOCAL_CONNECTIONS=8 as defaults that a value you set in .env still overrides. production.backend.config.js turns its single inline app into backend (web), trading and cron (only, 4001) for the same reason. The value is refused with an error at pm2 start in two layouts: combined with CRON_MODE=inline (a trading process must not also schedule, and an inline web process would race it for the engine), and in production.thread.config.js (worker threads run the matcher unarbitrated). Unset, all three configs produce exactly the app lists they produced before. The reverse proxy must move four path prefixes to the trading port or the trading routes reach a process that no longer hosts the engine; see the nginx and Apache sections and Two backend processes.production.config.js and production.backend.config.js pin the trading app's PORT and NEXT_PUBLIC_BACKEND_PORT to it, because the backend binds NEXT_PUBLIC_BACKEND_PORT for every role and the two must agree with the port the proxy names. Both configs refuse a value equal to the web backend's port or to 4001, the cron app's. Like 4000 and 4001 it is loopback traffic only: firewall it, and never hand it to a browser.ECO_TRADING_ENABLED is on: trading for the dedicated trading process (the only process that may hold the ecosystem-matching lease once the split is on), web for the web tier beside it (never a lease candidate). Unset is today's layout, where CRON_MODE alone decides and whoever runs CRON_MODE=off or inline hosts the engine. Any set value requires CRON_MODE=off; a process that finds the variable with CRON_MODE unset, inline or only, or with a value other than trading or web, refuses to boot with one line on stderr (Backend refused to start: ECO_PROCESS_ROLE=... requires CRON_MODE=off ...) and exit code 78, which PM2 treats as a stop rather than a restart loop. The refusal is deliberate: the value only has meaning inside the split layout, so a scheduling process finding it means it leaked in from .env or the shell, and a misspelt trading that booted as a web process would leave no process hosting the matcher. The engine health route reports the role as role.Database
backend/config.js treats an empty value as missing and logs a boot error, though it does not stop the process; the database backup and restore endpoints coerce it to an empty string instead. Set a real password.backup/: every run of POST /api/admin/system/database/backup prunes the directory back to this many, oldest first, and only files matching the generated YYYY_MM_DD_HH_mm_ss.sql name shape are considered. 0, an empty value and anything non-numeric fall back to 10, so pruning cannot be switched off. A negative value is not rejected and prunes every dump in the directory, so do not set one. There is no delete endpoint, so nothing prunes between backup runs.lazy (the default) only alters tables when the model fingerprint in backend/.sync-hash changed. none authenticates and touches nothing — the setting to reach for when you are diagnosing foreign-key churn. always forces a full ALTER sync, for a schema that drifted outside Sequelize. force DROPS and recreates every table and loses all data.It is not a repair mode. It drops every table and recreates it empty. If you are
trying to fix a schema that no longer matches the models, always is the escape
hatch.
Sessions and token secrets
All four are 128-hex-character values. The installer generates them with
crypto.randomBytes(64) on a fresh install. .env.example ships real-looking
sample values — replace them.
There is no fallback and no default. Any secret that is unset or shorter than 32
characters makes the route that needs it fail with a 500 at the moment it is
used, not at boot, so a bad APP_RESET_TOKEN_SECRET looks like "password reset
is broken" rather than "the platform will not start".
s, m, h, d parse; anything else throws a 400 on login. The template ships 30m; the code default when the variable is absent is 15m."true" only for a load balancer on a different host — it then believes a forwarding header from any peer, which is dangerous while the API port is reachable directly. "false" disables the header entirely (diagnostics only) and collapses every visitor into one rate-limit bucket.10.0.0.0/8. The safe way to trust a proxy on another host. Also unlocks the single-value CDN headers (CF-Connecting-IP, True-Client-IP, X-Real-IP), which are ignored by default because Apache and nginx forward them straight through from the client.Rate limiting
RATE_LIMIT_EXPIRY is still honoured for installs that already set it, but this name wins.RATE_LIMIT.Redis
Redis is a hard boot dependency, not a cache. Sessions, CSRF tokens, rate-limit
counters, distributed locks, the BullMQ scheduler and cross-process settings
invalidation all live in it. The backend exits with code 78 (EX_CONFIG) when
it is unreachable, printing the host and port it tried. Every PM2 config lists
78 in stop_exit_codes, so PM2 stops the app instead of crash-looping it.
sudo apt-get install -y redis-server && sudo systemctl enable --now redis-server
redis-cli -h 127.0.0.1 -p 6379 ping # expects: PONG.env.example.nodemailer-service, nodemailer-smtp, nodemailer-sendgrid or local.nodemailer-service: the well-known provider, e.g. gmail or outlook.tls, 465 with ssl.tls for STARTTLS on 587, ssl for implicit TLS on 465. Mismatching this with the port produces a connection that hangs rather than a clear error.APP_NODEMAILER_SMTP_USERNAME is set.nodemailer-sendgrid transport.sendmail binary, for the local transport. Find it with which sendmail..env.example ships APP_EMAILER="nodemailer-smtp" with port 587 and
tls. If you delete those lines rather than filling them in, the code defaults
take over — nodemailer-service, smtp.gmail.com, port 465, ssl — and mail
silently goes nowhere. Set every mail variable explicitly.
SMS
Twilio delivers every SMS the platform sends: login and 2FA codes, phone
verification, withdrawal and password-change codes, and notification messages.
The provider refuses to initialise unless the account SID starts with AC and
either a phone number or a messaging service SID is present.
AC.msg91 moves codes to MSG91 while Twilio still sends everything else. MSG91 cannot carry free-text notifications — its send API requires a registered template, and DLT caps each template variable at about 30 characters.tokenAuth from an OTP Widget snippet: that is a public browser token, MSG91 rejects it, and sends still report success. Verify at Admin → System → SMS Providers.##OTP## as the placeholder. OTP templates are approved instantly.Push notifications
pnpm vapid:generate.mailto: URI.Exchange providers
Which exchange is live is a database row set from Admin → Finance → Exchange Providers, not an environment variable. The backend then builds the credential names from the provider alias at runtime:
APP_${PROVIDER}_API_KEY
APP_${PROVIDER}_API_SECRET
APP_${PROVIDER}_API_PASSPHRASESo a grep for APP_BINANCE_API_KEY in the source finds nothing even though the
variable is load-bearing. Add the trio for whichever provider you activate.
bin, kuc, kra, okx, xt. Read only by the frontend chart and market-data code; zero backend readers, so it selects the chart symbols, not the trading connection.Fiat exchange rates
Every configured provider is queried each run and the results are merged, so coverage is the union — a currency one source is missing is still priced by another. The keyless providers alone cover roughly 159 of 160 currencies.
openexchangerates, exchangerate-api, open-er-api, currency-api, frankfurter. Providers whose key is absent are skipped automatically. Leave unset to use all of them.consensus takes the largest cluster of agreeing sources, which guards against a stale primary — OpenExchangeRates was observed serving SSP at 130 while three other sources agreed on ~4900. priority always takes the earliest-listed provider that has it. Either way, disagreement above 2% is logged with every source's value.APP_FIAT_RATES_PROVIDERS.CODE=units-per-USD overrides, for codes reused after a redenomination where sources disagree about which unit the code names. CODE=retired drops the currency. Read by the rate merger but not declared in .env.example.Deposit gateways
Each gateway's readiness is computed from these variables, not from the database
row — the credential names in backend/src/utils/deposit-gateway/registry.ts
are read straight out of process.env, and Admin → Finance → Deposit → Gateways
reports a gateway as unconfigured until they are present. All are optional:
leave blank for any gateway you do not enable.
Stripe, PayPal, Paystack
whsec_…), from the endpoint you create in the Stripe dashboard. Without it the webhook is refused with 503, not trusted — the route is public and it credits wallets. Payments then confirm only when the customer's browser returns, so a closed tab is a charge with no credit.true for the Paystack test environment. Unset means live — the code reads === "true", so anything else is production.TransFi (fiat on/off-ramp)
Sandbox and production credentials are not interchangeable: sandbox credentials
return UNAUTHORIZED_CUSTOMER against api.transfi.com, and vice versa.
NODE_ENV, for the reason above. https://sandbox-api.transfi.com or https://api.transfi.com. TransFi's own auth docs print api-sandbox.transfi.com; that host does not resolve, so do not "fix" this value to match them.other. Minimum 10 characters.raw (recommended) or python. Unset means try raw, then fall back and warn.false forces production when the base URL is unset.The other twelve gateways
test or live.NODE_ENV, not from a flag of its own.true for the PayFast sandbox. Unset means live — the code reads === "true", so anything else is production.true for the Paysafe test environment. Unset means live — the code reads === "true", so anything else is production.WEBSTAGING in test.true for the Paytm staging environment. Unset means live — the code reads === "true", so anything else is production.true for the PayU test environment. Unset means live — the code reads === "true", so anything else is production.FRONTEND_URL for a successful PayU payment.FRONTEND_URL for a failed PayU payment.FRONTEND_URL when the customer cancels.Both gateways build their return URLs as ${FRONTEND_URL}${path}. With
FRONTEND_URL unset the customer is sent to the literal string
undefined/finance/deposit?status=success&ref=… and never returns to the site.
Add FRONTEND_URL to .env before enabling either gateway.
Forex A-book execution
Hedge-execution venue credentials for the forex trading extension's A-book layer. These are not the market-data provider keys. All optional — leave blank for pure B-book operation.
AI services
Blockchain and the ecosystem extension
.env.example declares no RPC endpoint for any chain. A comment block
describes the naming convention and stops there, so every endpoint the ecosystem
extension needs has to be added by hand. Two things are exceptions. The explorer
and transaction-provider keys further down are in the template, under
Explorer / transaction-history providers. And custom EVM chains live in the
ecosystem_custom_chain table, are managed from Admin → Ecosystem → Custom EVM
Chains, and are written into process.env at boot from the database.
The naming convention is mechanical:
ETH_NETWORK="mainnet"
ETH_MAINNET_RPC="https://..."
ETH_MAINNET_RPC_WSS="wss://..."
ETH_EXPLORER_API_KEY="..."
# Optional, and worth setting: a second endpoint the chain falls back to.
ETH_MAINNET_RPC_FALLBACK="https://backup-1.example, https://backup-2.example"<SYMBOL>_<NETWORK>_RPC accepts a comma-separated list, so you can also
put several endpoints in the key you already have without learning a new one.
Both keys are read and combined, in order, and duplicates are dropped.
Give every chain a second endpoint if you can. Deposits, withdrawals, balance reads and swap broadcasting all run through the same provider, so one rate-limited or briefly-down node takes all of them offline at once — and free-tier public endpoints rate-limit constantly. With more than one configured the platform orders them by measured latency, drops one that fails three times in a row, and brings it back on its own when it answers again. With exactly one it behaves as it always has. See the chain RPC runbook.
<SYMBOL>_NETWORK selects the network (default mainnet), and the code then
reads <SYMBOL>_<NETWORK>_RPC and <SYMBOL>_<NETWORK>_RPC_WSS for that
network. <SYMBOL>_EXPLORER_API_KEY is the per-chain Etherscan key, tried
before ETHERSCAN_API_KEY rather than instead of it — the two lists are
concatenated, so a stale per-chain key no longer shadows a working global one.
The EVM symbols in use are ETH, BSC, POLYGON, FTM, OPTIMISM,
ARBITRUM, CELO, BASE, RSK, plus MO.
UTXO chains take node connection details instead: <SYMBOL>_NODE_HOST (default
127.0.0.1), _NODE_PORT, _NODE_USER, _NODE_PASSWORD, and
<SYMBOL>_MEMPOOL_API_URL. Non-EVM chains use their own families —
TRON_NETWORK and TRON_API_KEY, SOL_NETWORK and SOLANA_RPC_URL,
TON_NETWORK with TON_MAINNET_RPC and TON_MAINNET_RPC_API_KEY,
XMR_DAEMON_RPC_URL (default http://127.0.0.1:18081/json_rpc) and
XMR_WALLET_RPC_URL (default port 18083).
<SYMBOL>_EXPLORER_API_KEY of its own. Without it, transaction history, token metadata and contract verification lookups fall through to the keyless providers described below — which cover most chains, but not BSC, Fantom, Cronos, HECO or Polygon Amoy.true to run the ecosystem deposit monitors. Off by default.ARBIRUM_MAINNET_RPC and ARBIRUM_MAINNET_RPC_WSS — missing the second "T" —
are still read as fallbacks by the admin balance endpoint and the system health
check. The real provider path only reads the correctly spelled
ARBITRUM_MAINNET_RPC.
Set only the typo key and you get the worst outcome available: health reports
Arbitrum as Up while deposits and withdrawals are broken. Neither spelling is in
.env.example. Always set ARBITRUM_MAINNET_RPC.
Explorer and transaction-history providers
Seven providers serve EVM transaction history and native-deposit detection,
tried in a per-chain order with automatic failover. Two of them —
Blockscout and Routescan — need no credential, and each is appended to the end
of the order of every chain it can serve, so most chains work with none of these
keys set. Five chains are the exception: BSC (56 and 97), Fantom (250 and
4002), Cronos (25), HECO (128 and 256) and Polygon Amoy (80002) have neither a
hosted Blockscout instance nor Routescan coverage, so none of them gets a
keyless provider appended to its order. BSC is the one where a keyed provider
is the normal answer for a
production install: NODEREAL_API_KEY is free for BSC mainnet, and on BSC
testnet only MORALIS_API_KEY / COVALENT_API_KEY index it.
Every provider key, ETHERSCAN_API_KEY above included, may hold several
comma-separated keys, rotated through on auth, plan and rate-limit failures, and
every one has a chain-scoped form —
BSC_NODEREAL_API_KEY, POLYGON_COVALENT_API_KEY — that is tried first with
the global value behind it as a spare. The full per-chain picture is in the
Ecosystem environment reference.
true to stop the keyless Blockscout/Routescan tail being appended to the order you configured.Master wallet encryption
Two variables unlock every custodial private key on the install. Neither is in
.env.example. Generate them once, before creating any master wallet:
node scripts/kms/generate.mjsIt creates a 32-byte key, asks for a passphrase of at least 12 characters, and
writes the AES-256-GCM result back to .env as four colon-separated hex parts
(IV, auth tag, ciphertext, salt).
Change or lose either value and every encrypted wallet on the install becomes
permanently unreadable. There is no recovery path and no support workaround.
pnpm env-manager refuses to edit them for exactly this reason. Back up .env
somewhere the database backup does not live.
ScyllaDB
Ecosystem and futures order books, candles and trade tape live in ScyllaDB, not
MySQL. The installer does not install it and .env.example declares none of
these — the defaults below are what the code assumes when the variables are
absent. Neither the built-in database backup nor mysqldump covers this data.
false to disable Scylla entirely. Ecosystem trading then answers 503 rather than failing at boot, which is the right shape for an install that does not use the ecosystem extension.Licensing and product identity
.lic files under lic/. Undeclared in the template, and two code paths disagree about what happens when it is unset — one falls back to a build-time constant, the other to the literal string default-secret. Set it explicitly or leave it entirely unset; do not set it on one install and not another.updates.mashdiv.com must not be firewalled; there is a 72-hour grace period when it is unreachable.Two-factor policy
The five withdrawTwoFactor* platform settings in Admin → System → Settings are
the live controls. These three are the legacy fallbacks the login paths still
read, and they are undeclared in .env.example.
KYC document storage
backend/storage/kyc/documents. Set it to move the store onto a separately backed-up volume — which was impossible before 6.7.2, when documents lived in the public web root. Set it before running pnpm db:migrate:6.7.3:apply, or the migration moves documents somewhere the server does not look. Include this directory in your backups.Other operational variables
None of these are in .env.example either, but several change behaviour you can
observe.
true, 1, yes or on to suppress all outbound mail. Useful on a staging clone of production data.APP_NODEMAILER_SMTP_SENDER.TRUST_PROXY wins when both are present.debug also turns on verbose API request logging.0 to turn the report off. Use it when an operation is reported as slow and you need to know which step to look at: it names the wallet hold, the book read or the matching handoff rather than leaving you with one total.DB_POOL_MAX, never fewer than four.WALLET_BUSY (503) instead of hanging. Matches the pool's own acquire timeout by default.0 sends every change immediately, which is the older behaviour and is measurably more expensive on a market that has bots quoting it. The market data socket also re-sends a full book every two seconds regardless.backups/nft under the project root.Order admission and fail-fast
Everything in this group is off unless you set it, and with every variable
unset the order path is byte for byte what it was before. Each one bounds a
queue that a burst of bot orders could otherwise grow without limit, and
refuses the request in front of it instead of letting it wait: a refusal
written before the body is read costs the process a few tens of microseconds,
where an order that queues for thirty seconds and then fails costs the process
8 to 9 milliseconds of CPU and stalls every other route on the way. The
numbers quoted below were measured on the reference box (one backend process,
tuned MariaDB, quiet): it accepts about 50 to 62 place-and-cancel pairs per
second from bots (each pair is two requests through the gate), about 100
placements per second on an empty book, and one core is spent at 110 to 125
accepted requests per second. Your own figures
come from the same probes, GET /api/admin/ecosystem/engine/health and the
[ECO_ADMISSION] log line, both described in
Monitoring.
Two doors take orders: the session door (/api/ecosystem/order*, the trading
screen and the mobile app) and the signed Hummingbot door (/api/hb/order*).
A refusal renders on each in the shape that door already uses. The session
door answers HTTP 200 with {message, statusCode} in the body, as it does for
every refusal today, plus a Retry-After header. The Hummingbot door answers a
real status with {code, msg}: 429 as -1003, 503 as -1001. Nothing
changes for a request that is accepted.
Unless an entry says otherwise the value is read on every request, so a change
takes effect without a restart; pnpm env-manager set NAME=value --restart is
still the safe way to write it.
The shed gate, before the body is read
off leaves the doors exactly as before: no sampler, no counters, nothing on the request path. log takes every decision and counts it but refuses nothing, printing one [ECO_ADMISSION] warning line per second whenever something would have been refused; run this first and read that line for a day before you enforce anything. enforce writes the refusals. The gate sits before the body is read, before authentication and before any database or Redis call, which is what makes a refusal cheap: 30 to 40 microseconds rendered, against the 670 to 900 microseconds an order that reached the balance check and failed there used to cost. Read when the process starts; restart to change it./api/ecosystem/order* and /api/hb/order* alike. The unit is therefore requests through the gate, not placements: a bot fleet placing and cancelling 55 orders per second is 110 requests per second here. The bucket holds one second of admissions and refills with the clock. 0 is unlimited, and the only sensible value while nothing has been measured. Above the budget the request is refused with 429 (-1003 on the Hummingbot door) carrying X-RateLimit-Bucket: admission, X-RateLimit-Limit, X-RateLimit-Remaining: 0, X-RateLimit-Reset and Retry-After. Set it at the ceiling of your own box in requests per second, read from admitted on the [ECO_ADMISSION] line, which counts the same requests. On the reference box that ceiling was 111 requests per second with the box loaded and 209 with it quiet (55 to 104 placements per second with as many cancels beside them), and the value is load-dependent by that 2x: a budget sized for the quiet box refuses nothing while the box is quiet and lets the queue return when it is not, which is why the loop-delay signal below exists. A budget above the ceiling does not shed; a burst at 600 per second against a budget of 110 on the loaded box held one request open for 5.3 s with 28 queries waiting on the pool, while a budget of 70 kept every request under 3 s. Read when the process starts.-1001, Retry-After: 1) because the process is already behind. This is the adaptive half of the gate: ECO_ADMIT_PER_SEC is a fixed number that fits one load, and on a box that is shared or busy it is the loop delay that says the process is behind whatever the budget allows. 0 leaves the sampler observing only. Two facts set the floor. The sampler's histogram has a 20 ms resolution, so an idle loop reads a mean of about 30 ms, never zero; and on the reference box the mean sat at 20 to 34 ms at every load measured, with the p99 at 48 to 58 ms on a shallow book and 65 to 128 ms with four to five thousand resting orders. A threshold has to sit at about 50 ms or above the 30 ms idle floor to shed anything real; under 50 it sheds a healthy process, and 150 or more catches only a real stall. Read the figures from eventLoopDelay on the engine health route before choosing. Read when the process starts.-1001, Retry-After: 1). 0 disables it. On the reference box 40 requests in flight gave a placement p50 of 344 ms and p99 of 909 ms; 80 in flight exhausted the 25-connection pool from the first second (74 waiting) and the slowest wallet wait reached 1.7 s. A value between 40 and 80 per process keeps every accepted order under a second; the pool and wallet figures on the engine health route say where a request open past that would be waiting. Read when the process starts.One caller at a time
-1003), the X-RateLimit-* headers read without spending, Retry-After: 1 and its own message (Too many requests in flight for bucket 'trade' (limit N). Retry shortly. for a bot, Too many orders in flight. Please wait for your open requests to finish. for a person). 0 is off. 4 is the working value: a Hummingbot strategy keeps one or two orders in flight per tick, and four open requests on a door answering in 88 ms (the reference box's p50) still let one bot place about 45 orders per second on its own, most of what the whole process accepts. Read on every request.The wallet gate
These two sit beside WALLET_TX_CONCURRENCY and WALLET_QUEUE_TIMEOUT_MS
above. The deadline refuses a write that has already waited thirty seconds;
these refuse a hold, the write a new order takes on the wallet it spends,
the moment it arrives at a queue that is already long, so a hundred thousand
holds do not each arm a thirty-second timer and fail together late. Only holds
are refused. A release, a cancel refund, a fee credit, a transfer, a
settlement leg or an admin adjustment is never refused at entry, because a
refund that was refused would leave the ledger different from today.
WALLET_BUSY (503) with The wallet is busy: N ledger writes are already waiting for it and this one was refused without waiting. Please retry. A refused hold inside a placement still rolls the order back, so the trading screen sees the placement's own rollback message unless ECO_PREHOLD_ADMISSION catches it first. 16 is the working value: on the reference box the deepest queue on one wallet under a placement flood was 41 writers and its longest wait 1,719 ms, while a quiet bot mix on five wallets queued one to five; sixteen writers ahead of a hold is a few hundred milliseconds of wallet time, well inside the deadline, and a queue past that is a bot re-quoting faster than its wallet can commit. 0 is unbounded. Read on every hold.WALLET_TX_CONCURRENCY) before a new hold is refused without queueing, with the same WALLET_BUSY (503). Checked once, at entry, so a hold admitted past it is an ordinary waiter afterwards. On the reference box the slot queue reached 26 with 80 requests in flight against a pool of 25, which is the point where the pool has nothing left for any other query; about twice WALLET_TX_CONCURRENCY keeps the queue below that. 0 is unbounded. Read on every hold.1, true, on or yes to ask the wallet gate, immediately before the order is written to ScyllaDB, whether the hold it is about to take would be refused at entry, and to refuse the placement there with 503 instead: no order row is written and nothing is rolled back. The refusal is the same WALLET_BUSY message the gate would have produced. In this release the check consults the connection-slot bound (WALLET_QUEUE_MAX_SLOT); the per-wallet bound is still applied by the hold itself, after the row is written, as before. Off, the placement path is untouched. Read on every placement.The matching engine and ScyllaDB
-1001, Retry-After: 1) with The matching engine is busy: <operation> waited N ms for the engine lock. Nothing was changed and the order is still open; retry. The order really is untouched: nothing was written and the claim never ran. A Hummingbot cancel-all reports it per order under failed[].reason with a 200, as it reports every per-order failure. 0 disables the bound. On the reference box the claim itself costs 1.7 ms with 50,000 resting orders and 3.7 ms with 100,000, one matching cycle is well under a second, and the cancel p99 on the deepest shape was 909 ms, so a value of 1000 to 2000 refuses only a cancel that is stuck behind a stalled cycle. Read on every cancel.Served N cross-process cancel(s) this tick (ECO_CANCEL_DRAIN_PER_TICK=L); M wait for the next tick. 0 is unbounded. Only relevant to an install running more than one backend process; on the reference box the cancel-all door spends 13 to 18 ms of leader time per order, so a bound of 50 keeps a tick under a second. Read on every tick.-1001, Retry-After: 1) and The order store is busy: ... before any row exists: no order row, no index row, no hold, nothing to roll back, and the message's promise that nothing was changed is literally true. The rollback of a placement whose hold was refused afterwards is counted against the same gate but never refused by it, because a refused rollback would strand a funded OPEN order on money the customer was told they did not spend. 0 is unbounded and only counts. Nothing surfaces the gate's counters on a route in this release, so set the bound from your ScyllaDB's own capacity rather than from a figure the platform reports, and treat the message appearing under normal load as the bound being too low. Read on every placement.CANCELED status write and the price-level decrement) at once in this process. Both user doors, DELETE /api/ecosystem/order/{id} and the Hummingbot cancel, and every caller of the shared cancel helper (cancel-all per order, copy-trading close, OCO siblings, stop orders, the IOC sweep, the cross-process drain) run inside it. At the bound the cancel is refused at entry with 503 and Retry-After: 1, the claim is restored, and the order is exactly as it was: OPEN, funded, resting and claimable again; the message is never one Hummingbot reads as already gone. The claim's own bound (ECO_CLAIM_TIMEOUT_MS) stays in front of the gate and the matching cycle is kicked outside it, so a slot is never held across a cycle. In the IOC sweep a refused remainder stays resting for the next sweep. The slot counts the refund's MySQL time as well as the ScyllaDB writes. 0 is unbounded. Read on every cancel.The engine flags, behind a canary
The variables in this subsection change how the matching engine does its work without changing what it writes: the same fills in the same sequence, the same ledger rows with the same keys and amounts, the same wire responses, with a flag on or off. Each one exists because a cost that used to grow with the depth of the book (a walk over every resting order per cycle, a pass over every market per placement, a reconciliation that held the engine lock across the whole book) was measured and replaced, and each one is off until you turn it on. They are read through one registry inside the backend, and four rules hold for all of them:
- Off by default. A backend with none of them set runs the engine it ran before they existed, byte for byte.
- Confined to a canary.
ECO_ENGINE_CANARY_SYMBOLSnames the markets a flagged behaviour may run on; empty means every market. Start with one quiet market and widen it. - Money-affecting flags fail off. The flags marked money-affecting below
read as off, without a restart, whenever the process cannot hear a kill:
during boot until its settings-bus subscription lands, and whenever that
Redis subscription is lost. The cost-only flags stay as configured, because
losing one costs throughput, not rows. The registry also carries a kill
switch on the settings-bus channel
eco:scale:killthat turns every flag off in every process at once; in this release no admin screen or command publishes it, so the rollback you will actually use is to unset the variable and restart the process. - Mirrors are proven or not trusted. The v2 book keeps second copies of
the engine's resident orders;
ECO_ENGINE_SELF_CHECKcompares each copy against the original at every place the engine changes them, and a copy that diverges is reported before it can price anything.
All of them are read when the process starts, so change them with
pnpm env-manager set NAME=value --restart. A flag reaching the engine is
announced once in the log, per flag, with the lines quoted under each entry;
scaleFlags on the engine health route shows every flag as configured (what
the environment says) and effective (what the engine is doing right now),
and both are described in Monitoring.
The order to turn them on is the order below, one at a time, on the trading process:
-
Arm the check and pick the canary. Set
ECO_ENGINE_SELF_CHECK=logandECO_ENGINE_CANARY_SYMBOLSto one market that trades but does not carry your volume, then restart. -
Turn one flag on, restart, and read the announcement line for it in the log.
GET /api/admin/ecosystem/engine/healthon the trading port should show the flagconfigured: true, effective: true;effective: falsewith the variable set means the process was killed or cannot hear a kill yet, and the flag is doing nothing. -
Watch for a day. An error line tagged
ECO_SELF_CHECKis a mirror that disagreed with the engine's own list;Self-check DISABLED itselfafter three of them is the checker giving up on the mirrors. Either one is "turn the flag off and report it", not "raise the limit". -
Widen the canary, then repeat from step 2 for the next flag. Leave the self-check in
logmode on the canary as long as you like; its cost is a linear pass over the market's resident orders at each check site, which is what the flags exist to avoid on the markets outside the canary.
BTC/USDT,ETH/USDT), trimmed and upper-cased, that every engine flag below is confined to. Empty or unset is no restriction: the flags alone decide, on every market. A symbol spelt in lowercase still matches, because the engine stores symbols upper-cased and a canary that silently matched nothing would be a rollback that did not roll. Also confines the placement-side reader of the v2 book. Read at boot.off runs no check. log compares them at every check site (the start and end of every matching cycle, every cancel claim, every restore of a claimed order, every resync ingest, and the aggregate reconciler's dual check) and writes one error line tagged ECO_SELF_CHECK per divergence, naming the site, the mirror and both values; after three divergences the checker disables itself with Self-check DISABLED itself at <site> after N divergences (limit 3). The mirrors are not trusted; the linear derivation is no longer paid for. Restart the process after the cause is fixed. and stops paying for the comparison. throw makes the first divergence throw from the mutation site; it exists for the test gates and must never be set in production, because an engine that throws under its own lock is worse than an engine with a stale mirror. The check has nothing to compare unless ECO_ENGINE_BOOK_V2 is on for the market. Read at boot.Resident book v2 ON for <symbol> (ECO_ENGINE_BOOK_V2): built from N resident order(s) and Resident book v2 OFF for <symbol>: dropped, the array is the only structure again. Required by ECO_RECONCILE_FROM_AGGREGATE. Read at boot.ECO_CYCLE_FULL_PASS_MS a cycle visits every market again as the backstop. Markets outside the canary are visited by every cycle as before. Announced once as Dirty-symbol cycles ON (ECO_CYCLE_DIRTY_ONLY): cycles visit marked symbols, all of them every N ms and Dirty-symbol cycles OFF: cycles visit every symbol. Read at boot.ECO_CYCLE_DIRTY_ONLY is on: how long, in milliseconds, a market may go unvisited before a cycle walks every market again. 0 makes every cycle a full pass, which costs exactly what the flag off costs; a fractional value is floored; empty, negative or unparseable reads as the default. Cost-only: it decides when an untouched market is re-walked, and a walk over unchanged orders settles nothing. Read on every cycle.0, false, off or no: a completed cancellation waits for the matching cycle it triggers before answering, as it always has. 0: on a canary market the cancel marks the market, releases its claim, schedules the cycle and answers at once. The refund is sized from the claim either way, the response body is identical, and the fills and ScyllaDB writes of the scheduled cycle are the same as the awaited one; what changes is the cancel's latency on a deep market, which no longer includes a cycle. The sense of the kill switch is reversed for this flag: a kill, or a settings bus that cannot deliver one, lands on the awaited path. Cancel-all keeps its awaited per-market cycle whatever the value. Announced once as Cancel cycles SCHEDULED (ECO_CANCEL_AWAITS_CYCLE=0, first on <symbol>) ... and Cancel cycles AWAITED again .... The engine health route's effective for this name reads the variable, not the engine's behaviour, so it can read false under a kill while the engine awaits. Read on every cancel.OPEN on that market, and admit it by the same rule as a window row (a price outside the resident band of a windowed market stays in the index for the window to slide to); the once-a-second drain of the durable dirty set reads one window per market per pass and forgets every keyed entry that read covered. In this release the placement path still publishes the bare market symbol, so the leader takes the window read whatever the value: the consumer half is built and the producer half is not. When a later build carries the key, turn it on only once every backend process runs that build, because a leader on this build drains a keyed entry as a market name it does not know and the order waits for the leader's 60-second sweep. Confined to the canary at the engine. The ingest line gains a suffix: Ingested N order(s) for <symbol> placed by another process (keyed nudge). Read at boot.ECO_BOOK_WINDOW_PER_SIDE) drops below its low-water mark, the engine refilled it by re-reading the whole window for the market. With this on, on a canary market, the refill reads only the band beyond that side's boundary price, as a clustering-key range on the market's index partition (price < boundary for bids, price > boundary for asks, inclusive when the boundary level is only partly resident), with a per-side limit of the window minus what that side still holds, through the same split-level rule the boot load uses. The one observable difference is a display one: an order another process placed inside the resident band is picked up by its nudge, the drain or the 60-second sweep rather than by the next refill. The window-slide line gains through a band read beyond the boundary. Read at boot.ECO_ENGINE_SELF_CHECK armed the round also derives the same levels linearly and, on a disagreement, repairs from the engine's list rather than from the copy and reports it. The repairs are the same rows with the same amounts as the old sweep; only the order of the inserts inside one market's list of repairs differs. A kill, or a settings bus that cannot deliver one, lands on the old sweep under the lock. Announced once as Reconciler from the aggregate ON (ECO_RECONCILE_FROM_AGGREGATE): canary symbols with a v2 book ... and Reconciler from the aggregate OFF: the linear sweep runs under the engine lock for every symbol; a round that starts while the previous one is still running logs Orderbook reconciliation round skipped and does nothing. Requires ECO_ENGINE_BOOK_V2. Read at boot.Cancel-all in batches
DELETE /api/ecosystem/order/all) and the Hummingbot batch cancel (DELETE /api/hb/order, up to 200 per call). 0, absent or unparseable is the per-order loop as before: one engine claim of every open order, then one wallet transaction and one ScyllaDB level update per order, about 18.5 ms of leader time each on the reference box. A value (the plan suggests 50) keeps the single claim and then groups the refunds per wallet: one wallet transaction per N orders with a database savepoint per order, so one refused order rolls back only itself and the batch continues; one ScyllaDB batch per price level carrying the sum of the per-order decrements instead of N read-modify-writes; then one matching cycle per touched market. Rows, idempotency keys, amounts and the response body are identical either way; what changes is the commit envelope, and two things that follow from it: a deadlock or lost connection inside a batch fails every order of that batch at once (rolled back, claims restored, each reported failed), and the wallet's row lock is held for the whole batch, 150 to 500 ms at 50, during which that wallet's fills wait. Every batch reads the engine lease's epoch under lock and compares it with the epoch read at the start of the run, so a run that straddles a promotion of another process stops with its remaining orders reported failed and their claims restored, nothing released twice; an install whose lease has never been claimed through MySQL has no epoch row and runs unfenced, which is logged once. The IOC sweep still cancels remainders one at a time in this release. The kill switch forces 0. Read on every call.The ledger batcher
off, holds or all, and any other value (including 1 or true) reads as off, so a boolean-style setting cannot switch a money path by accident. holds: a placement's hold on the wallet it spends, which used to open a MySQL transaction of its own, is instead submitted to one batcher per process and committed with every other hold of the same tick in one transaction; the placement still waits for that commit before the order becomes matchable. all: additionally every fill's ledger legs (the buyer's and seller's credits and hold drains, and the AI market maker's pool update when a bot is one side) are submitted as one group of the next tick. The rows written are the rows the verbs write today, with the same keys, amounts, types and descriptions, and a replayed key still answers as a duplicate with the existing row's id; what changes is the commit boundary, recorded as three allowlisted differences: a group is applied whole or not at all (a refused leg discards its sibling legs; today each leg was its own transaction), a batch-level failure (a deadlock or lost connection past three retries, an epoch mismatch, a stopped batcher) fails every operation of that tick at once, each placement rolling its order back and rendering as the same 500 a deadlock renders today, and a wallet-gate deadline hit inside a batch renders as that 500 rather than the gate's 503. The batcher is fenced by the engine lease's epoch: the leader bumps the epoch on every arm and every tick reads it under lock, so a deposed leader's next tick aborts and its batcher refuses everything until the process re-arms; a lease never claimed through MySQL has no epoch row and the batcher commits unfenced, logged once as Ledger fence NOT installed. The kill switch and a settings bus that cannot deliver one read as off without a restart. A hold handed an external transaction, and every add (a refund, a release, a fee credit) outside all, stays on the verb. Keep innodb_flush_log_at_trx_commit=1 while this is on: the tick's commit is what the placement was told happened. The batcher's tick figures are on the engine health route under ledgerBatcher. Read on every hold and every fill.The shard tier
Everything in this block is off on a stock install, and an install that sets none of it runs the single matching engine of the sections above. Turning it on is a topology change, not a tuning knob: read Sharding the matching engine before setting any of them, because the ones that place a symbol on a shard have to agree across every process or two engines end up over one order book.
production.config.js all read it, and the router places a symbol by hashing it modulo this number, so a process that disagrees sends orders to a shard that is not matching them. 1 (the default) is the single engine of today: no shard process, no door, nothing to agree about. Raising it is a planned migration, not a restart: a symbol whose shard changes must have its book drained first, which is what ECO_SHARD_MAP_FILE exists for. Read at boot.0 to ECO_SHARDS - 1. Set per app by production.config.js (one shard-<id> app per id), never in .env, which would give every shard the same id. A value outside the range, or one that cannot be parsed, refuses the boot with a message naming both numbers rather than reading as 0: a mistyped id used to make a second process silently believe it owned shard 0's symbols, which is two matchers on one book. Read at boot; a shard keeps one id for its life.0; shard N listens on this plus N. The door reaches its shards here, and both sides compute the port with the same function so they cannot drift apart. Like the backend and cron ports this is loopback traffic only — the shard transport carries no authentication of its own, because everything that reaches it has already been through the door's session, key and permission checks. Firewall it and never let it face a network. Read at boot on a shard, and when the door builds each client.ECO_LEDGER_TICK_MS: the effective wait is whichever is longer. A shard with nothing dirty does not tick at all. Read per scheduled tick, so a change needs no restart.{"version": <int>, "shards": <int>, "overrides": {"BTC/USDT": 2}, "signature": "<hmac>"}. A file that is configured and cannot be read, parsed, verified, or whose shards disagrees with ECO_SHARDS stops the process rather than falling back to the hash — a partial fallback is how two shards end up on one book. Unset (the default) is the plain hash, which is what a deployment that never pins a symbol wants. Read at boot.signature matches the content; a map with no overrides needs no secret. Treat it as a credential: anyone who can sign a map can move a symbol to a different shard, which is why it is the one scale variable the admin diagnostics payload never prints. Not in the template. Read at boot.<dir>/shard-<id> and takes a lock file in it, so two processes cannot write one log. It is also where the ordinary (unsharded) engine keeps its fee journal, which is the one use that works with no shard tier at all: set it and the leader replays a fee that was owed when it crashed instead of dropping it. Put it on the same durable volume class as the database, not on a tmpfs. Read at boot.0 leaves the module's own decision, which is to snapshot only when asked. A snapshot never covers a record whose ledger work is still owed, so raising it costs replay time and never correctness. Read after every cycle.SELECT ... FOR UPDATE is still the authority either way, so a longer interval costs accuracy and never money: too high a figure lets a fill through that the ledger then refuses, too low a figure refuses a fill the maker could have funded and it re-quotes. 0 re-reads after every cycle that touched a pool. Read at the end of a cycle.shards[].reconcile. 0 turns it off, which turns off the only thing that would notice either condition. Read at boot and on every promotion.none is own nothing, which is what the shadow stage needs; a list like BTC/USDT,ETH/USDT is exactly those and nothing else. Order and case do not matter — the list is normalised and sorted so two processes given it differently still agree. Put it in the signed ECO_SHARD_MAP_FILE instead if you want it tamper-evident; a file whose list contradicts this variable refuses to load rather than choosing one. If nothing claims a symbol it rests unmatched — the deliberate direction of the failure, and the matcher logs what it is standing down from at boot. Not ECO_ENGINE_CANARY_SYMBOLS, which confines a flag and has never routed an order. Read at boot; changing it needs a restart, because moving a symbol between owners without a quiesce is how a filled order gets matched twice.ECO_SHARD_SYMBOLS refuses the boot, because shadowed and owned are opposites. Set it on the doors (which send the copies) and on the shadow process. Read at boot.ECOSYSTEM_SHARD_SHADOW_<id> so it can never hold a real shard down. Its verdict is shards[].health.shadow on the engine health route, where cleanPasses counts consecutive passes on which its book matched the live engine's and resets to zero the moment one does not. Pointless without ECO_SHARD_SHADOW_SYMBOLS, which names what it is copied. Read at boot.0; shadow N listens on this plus N. Its own range so a shadow can run beside a real tier without either fighting for a port. Unset, it is ECO_SHARD_PORT_BASE plus 100, which both the shadow and the door that copies to it compute the same way. Loopback only, exactly as ECO_SHARD_PORT_BASE. Read at boot on the shadow, and when a door builds its shadow client.ECO_RECONCILE_MS and the same cost — reads only — but pointed at a projection the live engine wrote, so a difference means the two matchers disagreed rather than that a projector dropped a write. A minute rather than five because a shadow is a temporary arrangement someone is watching, and because the comparison is order-insensitive: more passes sharpen the signal instead of adding noise. Falls back to ECO_RECONCILE_MS, then to a minute. Read at boot and on every promotion.503 with Retry-After: 1, and the correlation id the door mints makes that retry safe: the same request cannot hold twice. Off (and while the settings bus is degraded) the local path runs, which on a process that does not hold the lease refuses rather than places — so the fallback can never put two matchers over one book. Requires the shard tier to be running. Read per request.GET inside the projection's lag window is covered by the door reading through to the shard. Read at boot.userOrders and userTrades — skip their per-user database read when that user's orders have not moved since the last one. Both re-read the user's whole order partition (up to 1,000 rows) every second whether or not anything happened, and the frame they then build is almost always suppressed as identical to the last one sent, so the read is the entire cost: at 200 accounts running bots that is 400 thousand-row partition reads a second for accounts that may not have traded all day. The frames do not change. The same builder runs over the same rows with the same filter, sort and cap; only the decision to read moves, so nothing a connector parses is affected. userBalances and userPositions are deliberately NOT gated — a deposit, a transfer or a mark-price move changes those with no order involved. Off by default. Pair it with HB_WS_RECONCILE_MS.HB_WS_ACTIVITY_GATE is on. This is the staleness bound on everything those frames derive from the CLOCK rather than from an event — the 15-minute recency window that decides which orders appear, and the 200-order cap applied after it — and it is the recovery path for a userTrades frame that back-pressure dropped, since that feed sends deltas rather than snapshots. Lower it to trade reads for freshness; the default is a good balance for a bot that reconciles over REST anyway. Values below 1000 ms are ignored and the default is used, because a floor shorter than the poll interval would mean no gating at all.The Hummingbot limiter and key cache
Both belong with HB_RATE_LIMIT and HB_AUTH_FAIL_LIMIT under Rate limiting
above, and ship with the Hummingbot Connector addon.
true (the default) holds every budget a key has (trade, account, read) to a bucket kept inside the process at a quarter of the configured limit, and reports that smaller cap in X-RateLimit-Limit, so the wire tells the bot the budget it is actually being held to. false, 0, off or no restores the older reading, unlimited while Redis is down. The unsigned market-data endpoints (ticker, order book, trades) keep failing open either way. The local bucket cannot coordinate across processes, so an install with several backend processes may still grant up to a quarter of the limit per process during an outage. Read on every request.hb:apikey:invalidate). A key changed any other way, by direct SQL or an admin route that does not announce, stays valid in every process until the TTL. 0 is off: one read per request, as before. Leave it off unless the key lookup shows in the slow-request line. Read on every request; a change to 0 stops the cache at once.WebSocket ingress
These apply to every WebSocket route the backend registers, not only the market feed. The first four are handed to the socket server when a route is registered, so they need a restart; the last three are read per frame.
0 disables. The socket server rounds to four seconds and requires 0 or at least 8; the value is passed through unvalidated. Restart to change.0 disables both. Restart to change.1, true, yes or on to close a socket the moment a frame is dropped at the backpressure limit, instead of leaving it open with a gap in its stream. Restart to change.message budget exceeded. 0 is unlimited and leaves the old path untouched. The Hummingbot stream keeps its own budget (HB_WS_MAX_MSGS_PER_SEC, 20) and its own error frame, and this one pre-empts it only when set lower. Read per frame.{type: "subscription", status: "error", message} frame and Subscription limit reached (N per connection); one the socket already holds is never refused, and a second browser tab has its own allowance. 0 is unlimited. The Hummingbot stream keeps HB_WS_MAX_SUBSCRIPTIONS (200) on its own frame shape. Read per frame.0 is off: one read per SUBSCRIBE, as before. Read per SUBSCRIBE.Ledger retention
The two ledger tables, transaction and wallet_audit_log, gain a row for
every hold, release, fill leg and fee, and nothing removed one. These four
variables drive a scheduled job on the cron process that moves old, finished
rows into two archive tables with the same columns and hard-deletes them from
the live ones. What moves, what never moves, how to run it by hand and how to
put a row back are in Backup and restore.
All four are read on every run.
true, 1, on or yes lets the hourly ledgerArchive job run; anything else, and the job returns before it opens the database. On, rows of transaction whose createdAt is older than the cutoff, whose status is COMPLETED, CANCELLED or FAILED, and that no foreign key points at (the fee credits admin_profit references, invoices, gateway payments) are copied to transaction_archive with their wallet_audit_log rows to wallet_audit_log_archive, then deleted from the live tables, one MySQL transaction per batch, with the live row deleted only after the archive has been read back holding it. A PENDING, PROCESSING or FROZEN row never moves. The job's DELETE cost counts against the same INSERT ceiling the order path spends, which is why it runs on the cron process on an hourly period rather than continuously.createdAt is strictly before now minus this many days are candidates; the day itself keeps its rows. Unparseable or below 1 reads as the default. The manual script accepts --after-days and refuses 0 for the same reason.1 reads as the default.0 is unbounded. The manual script defaults to unbounded and takes --max.Variables the code reads that the template never declares
Roughly two hundred variable names are read somewhere in backend/src and
appear nowhere in .env.example. Most are tuning knobs with sane defaults.
These are the ones that change whether something works:
Money and wallets
ENCRYPTED_ENCRYPTION_KEY, ENCRYPTION_KEY_PASSPHRASE — every custodial
private key on the install. Unrecoverable if lost.
FRONTEND_URL — PayU and Authorize.Net build customer return URLs from it.
Unset produces undefined/finance/deposit?....
ARBITRUM_MAINNET_RPC — the correctly spelled key. The typo variant is read as
a fallback by health checks only.
Infrastructure
Every blockchain RPC endpoint: roughly 60 names across the
<SYMBOL>_NETWORK / <SYMBOL>_<NET>_RPC families plus the UTXO node and
non-EVM families described above.
All eight SCYLLA_* variables. Ecosystem and futures trading do not work
without a reachable cluster, and no backup in the product covers its data.
REDIS_DB — the logical database index. Declared readers exist; the template
stops at host, port and password.
TRUST_PROXY and HB_TRUST_PROXY — two independent proxy-trust flags, both
required behind a reverse proxy.
Auth and policy
NEXT_PUBLIC_2FA_EMAIL_STATUS, NEXT_PUBLIC_2FA_SMS_STATUS,
NEXT_PUBLIC_2FA_APP_STATUS — read by every login path and the withdrawal 2FA
resolver.
SUMSUB_API_KEY, SUMSUB_API_SECRET — the Sumsub KYC integration.
LICENSE_SECRET, MAIN_PRODUCT_ID, HEARTBEAT_INTERVAL.
APP_NODEMAILER_SMTP_USERNAME, APP_EMAIL_FROM, APP_EMAIL_FROM_NAME,
APP_NODEMAILER_ALLOW_INSECURE_TLS, the three
APP_NODEMAILER_DKIM_* variables, and MAIL_DISABLED.
Duplicate naming families
A third set of names for values you have already configured, each read by
exactly one file. EMAIL_PROVIDER, EMAIL_FROM, SENDGRID_API_KEY,
SMTP_HOST and SMTP_PORT are read only by the admin notification-settings
screen; SITE_NAME and SITE_DESCRIPTION only by the API docs generator;
APP_DEFAULT_LOCALE only by the payment gateway extension.
Setting them does not configure mail or the site name. Use the APP_* and
NEXT_PUBLIC_* names documented above.
Legacy alias
RATE_LIMIT_EXPIRY is honoured as a fallback for RATE_LIMIT_EXPIRE. For years
the code read one spelling and the template shipped the other, so the window was
permanently 60 seconds and editing the documented variable changed nothing. Both
work now; prefer RATE_LIMIT_EXPIRE.
Variables in the template that nothing reads
Setting any of these has no effect anywhere in the product. They are listed so you stop trying.
| Variable | Note |
|---|---|
OPENAI_API_KEY |
The AI verification path supports Gemini and DeepSeek only. No OpenAI SDK is imported anywhere in the backend. |
APP_CLIENT_PLATFORM |
Twenty lines of instructions in the template for a value with no reader. |
APP_SUPPORT_PHONE_NUMBER |
No reader. |
NEXT_PUBLIC_FRONTEND |
No reader. |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
Commented out. Consumed by Next.js internals if uncommented, never by application code. |
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID, NEXT_PUBLIC_FACEBOOK_PIXEL_ID and the
googleAnalyticsStatus / facebookPixelStatus switches used to be on this
list. They have been removed from the template and from Settings: no analytics
or pixel script is loaded anywhere in the product, so there was nothing for
them to switch on.