Settings key reference
Every key behind Admin → System → Settings by its real name — tab, label, type and shipped default — plus the two native-app version floors, the 21 geo keys the main save refuses, the 44 Super-Admin-only keys, and the two caches a hand-edited row never reaches.
Settings reference describes the settings screen by
what each control is called. This page is the same surface by what each row is
named — the string in the settings table, in the API payload, and in every
log line. It is the page to open when you are querying the database, writing a
migration, reading a support answer that quotes a key, or trying to work out why
a save was refused.
The registry is frontend/config/settings.ts. It defines 76 fields across
seven tabs, and it is the only thing that decides which keys the screen at
/admin/system/settings can write. A key that exists in the database but has no
entry there is enforced by the backend and editable from nowhere on that screen.
Three rules that catch everyone
Values are TEXT. The column is LONGTEXT and the handler runs String()
over everything it is given, so an off switch is stored as the five characters
false — not a boolean, not 0. Querying the table directly, if (value) is
true for an off switch. Every reader in the platform goes through a coercer
that accepts true/1/yes/on and false/0/no/off; hand-written SQL
does not.
The save sends only what changed. The settings screen diffs your draft against the loaded values and posts a payload containing just the differences. Unchanged keys are also dropped a second time server-side, against the current row.
A key with no field is still enforced. The backend reads the row whatever
the screen does or does not render. That is true of the showIf conditions
below, and it is true of the keys that have no field at all.
The 76 keys
Defaults come from DEFAULT_SETTINGS in the same file. A dash means the key
ships with no default and reads as empty until you save it once.
General
| Key | Group | Label | Type | Default |
|---|---|---|---|---|
siteTheme |
Appearance | Default Site Theme | select (light, dark, system) |
— |
layoutSwitcher |
Appearance | Theme Switcher | switch | true |
navbarLogoDisplay |
Appearance | Navbar Logo Display | select (SQUARE_WITH_NAME, FULL_LOGO_ONLY) |
SQUARE_WITH_NAME |
landingPageType |
Landing Page | Landing Page Type | select (DEFAULT, CUSTOM) |
— |
newsStatus |
Content | News Section | switch | true |
floatingLiveChat |
Support | Floating Live Chat | switch | true |
verificationBadge |
Trust | Registry Verification Badge | switch | false |
addon_aliases |
Addon Branding | Addon Display Names | custom | — |
Features
| Key | Group | Label | Type | Default |
|---|---|---|---|---|
investment |
Investment | Investment | switch | true |
kycStatus |
Verification | KYC Verification | switch | true |
kycFeatureEnforcement |
Verification | Enforce KYC Feature Access | switch | false |
dexEnabled |
Web3 Trading | Web3 Trading | switch | false |
One dex* key on this screen, and only one. dexEnabled has to live here,
because the Web3 Trading console answers 503 while the switch is off and so can
never be the screen that turns it on. Everything else about the addon —
dexAllowlistMode, dexKycRequired, dexDirectPoolsEnabled,
dexPoolRiskAckRequired and the other 66 keys (slippage bounds, timeouts,
screening, the poller, the pool indexer) — is edited in that console.
dexAllowlistMode and dexKycRequired used to have fields here as well. They
were removed in 6.6.4, and this endpoint now answers 403 for either of them
with a pointer to the console. Two editors for one row is bad enough on its own;
what made it worth a refusal rather than a tidy-up is that only one of the two
validated. This endpoint checks a key's shape and a value's length and nothing
else, so it would happily have parked dexAllowlistMode on a string no reader
recognises — and the consequence of an unrecognised allowlist mode is a customer
swapping into a token nobody curated, which cannot be undone.
Security
| Key | Group | Label | Type | Default |
|---|---|---|---|---|
googleAuthStatus |
Authentication | Google OAuth Login | switch | true |
verifyEmailStatus |
Authentication | Email Verification Required | switch | true |
twoFactorStatus |
Two-Factor Authentication | Two-Factor Authentication | switch | true |
twoFactorSmsStatus |
Two-Factor Authentication | SMS 2FA | switch | true |
twoFactorEmailStatus |
Two-Factor Authentication | Email 2FA | switch | true |
twoFactorAppStatus |
Two-Factor Authentication | Authenticator App 2FA | switch | true |
withdrawTwoFactorRequired |
Withdrawal Security | Require 2FA to Withdraw | switch | false |
withdrawTwoFactorChallenge |
Withdrawal Security | Verify 2FA on Every Withdrawal | switch | false |
withdrawTwoFactorAppAllowed |
Withdrawal Security | Accept Authenticator App | switch | true |
withdrawTwoFactorEmailAllowed |
Withdrawal Security | Accept Email 2FA | switch | true |
withdrawTwoFactorSmsAllowed |
Withdrawal Security | Accept SMS 2FA | switch | true |
captchaProvider |
Protection | Captcha Provider | select (turnstile, recaptcha, hcaptcha, pow, none) |
pow |
captchaSiteKey |
Protection | Site Key | text | (empty) |
captchaSecretKey |
Protection | Secret Key | text (password) | (empty) |
captchaScoreThreshold |
Protection | reCAPTCHA Score Threshold | range 0–1 step 0.1 | 0.5 |
powCaptchaDifficulty |
Protection | PoW Difficulty | select (low, medium, high) |
medium |
powCaptchaStatus |
(no longer rendered) | — | — | true |
Integrations
| Key | Group | Label | Type | Default |
|---|---|---|---|---|
googleAnalyticsStatus |
Analytics & Tracking | Google Analytics | switch | false |
facebookPixelStatus |
Analytics & Tracking | Facebook Pixel | switch | false |
Wallet
| Key | Group | Label | Type | Default |
|---|---|---|---|---|
fiatWallets |
Wallet Types | Fiat Wallets | switch | true |
deposit |
Transactions | Deposits | switch | true |
withdraw |
Transactions | Withdrawals | switch | true |
transfer |
Transactions | Transfers | switch | true |
withdrawProcessingTime |
Transactions | Withdrawal Processing Time | switch | true |
withdrawAutoApprove |
Security | Auto-Approve Withdrawals | switch | false |
depositExpiration |
Security | Deposit Expiration | switch | false |
transfiIbanEnabled |
TransFi | TransFi Virtual IBANs | switch | false |
transfiOnrampEnabled |
TransFi | TransFi Buy Crypto (Onramp) | switch | false |
transfiOnrampCustody |
TransFi | Buy Crypto Delivery | select (self, platform) |
self |
transfiOfframpEnabled |
TransFi | TransFi Sell Crypto (Offramp) | switch | false |
transfiOfframpCustody |
TransFi | Sell Crypto Source | select (self, platform) |
self |
walletTransferFee |
Fees | Wallet Transfer Fee | range 0–10, step 0.1, % |
1 |
walletTransferSpread |
Fees | Wallet Transfer Spread | range 0–10, step 0.1, % |
0.5 |
spotWithdrawFee |
Fees | Spot Withdraw Fee | range 0–10, step 0.1, % |
1 |
walletCurrencyIcons |
Display | Currency Icons | switch | true |
withdrawApproval is not in that table and no longer has a field anywhere. It
is the legacy form of withdrawAutoApprove, its name reads backwards ("true"
means auto-approve), and the spot withdraw route still falls back to it when
withdrawAutoApprove has no row — so it is a key that still matters and no
longer has an editor. That is deliberate: saving Auto-Approve Withdrawals
writes both rows, so the fallback cannot resolve to something the panel is not
showing. See
Withdrawal policy for the upgrade
migration.
Social & Links
| Key | Group | Label | Type | Default |
|---|---|---|---|---|
customSocialLinks |
Social Media | Social Links | socialLinks (JSON array) | four seeded links |
appStoreLink |
Mobile Apps | App Store Link | url | "" |
googlePlayLink |
Mobile Apps | Google Play Link | url | "" |
mobileMinVersionIos |
Mobile Apps | Minimum iOS App Version | text | "" |
mobileMinVersionAndroid |
Mobile Apps | Minimum Android App Version | text | "" |
customSocialLinks is stored as a JSON string, one object per link with
id, name, url and icon.
The two minimum-version keys
They are the floor below which the native apps refuse to run. A build older than the value for its platform shows a blocking update screen with a button to the matching store link, and cannot be dismissed past.
Write a plain dotted version — 1.4.0. Leave the field empty to impose no
minimum, which is what a fresh install has: initial.sql seeds no settings at
all, so both rows are absent until somebody saves them.
Three things worth knowing before you set one:
- Set them per platform. Retiring an old iOS build does not retire the Android one, and a client only reads the key for the platform it is running on.
- Set the store link first.
appStoreLink/googlePlayLinkship empty, and the update screen shows no button when its link is missing — a dead "Update" that does nothing is worse than a screen that only explains, because a user taps it repeatedly and concludes the app is broken rather than out of date. - Anything the app cannot read means "no floor". An empty value, a value it
cannot parse (
v2,2.0.0-rc1,latest), or a settings response it never received all resolve to "do not block". That is deliberate and it is the only safe direction: a gate that failed closed would turn one mistyped value into every install on earth showing a wall, with no way to recover from inside the app.
These are read from GET /api/settings, which is unauthenticated — the app has
to be able to learn its own version floor while signed out. Both values are
therefore world-readable, like every other row on that endpoint.
Branding
Every key on this tab is a file upload holding a URL. The declared size is
what the uploader enforces on the image.
| Key | Group | Label | Size |
|---|---|---|---|
logo |
Site Logos | Square Logo (Light) | 96×96 |
darkLogo |
Site Logos | Square Logo (Dark) | 96×96 |
fullLogo |
Site Logos | Full Logo (Light) | 350×75 |
darkFullLogo |
Site Logos | Full Logo (Dark) | 350×75 |
cardLogo |
Site Logos | Card Logo | 256×256 |
favicon16 · favicon32 · favicon96 |
Favicons | Favicon | 16 · 32 · 96 |
appleIcon57 · 60 · 72 · 76 · 114 · 120 · 144 · 152 · 180 |
Apple Touch Icons | Apple Icon | as named |
androidIcon192 · 256 · 384 · 512 |
Android Icons | Android Icon | as named |
msIcon144 |
Microsoft Icons | MS Icon | 144×144 |
Saving this tab is two steps, not one: each chosen file is uploaded first
(resized to the declared size, into the settings upload directory, replacing
the previous file), and only the resulting URL is then sent to the settings
PUT as the key's value. So the stored value is always a path, never image data.
addon_aliases is a JSON blob with its own editor
The custom type means this field is not rendered by the generic settings form
at all. AddonAliasesField draws it: one card per enabled extension, with a
text input per renameable surface.
The stored value is a JSON string shaped
{"<extension>": {"menu": "...", "wallet": "..."}}. Two contexts exist —
menu (the navigation label) and wallet (the wallet-type label) — and an
extension only gets the inputs it actually has a surface for. Addons with
neither are listed in a footnote on the card rather than given a dead field.
Clearing an input, or typing the default name back into it, removes that
context from the blob rather than storing the default. Reset on a card deletes
that extension's whole entry; Reset all writes {}. The blob is cosmetic — it
changes labels in menus and wallet lists and nothing internal.
The 21 geo keys the main save refuses
These are stored in the same settings table but are not settings-screen
keys. PUT /api/admin/system/settings throws 403 on any of them, by name,
case-insensitively:
"<key>" is a geographic restriction control and can only be changed from Geo Restrictions → Policy
| Key | Default |
|---|---|
geoRestrictionEnabled |
false |
geoRestrictionMode |
BLOCKLIST |
geoRestrictionAllowAccountExit |
true |
geoRestrictionBlockUnknownCountry |
false |
geoRestrictionFailOpen |
true |
geoRestrictionAdminBypass |
true |
geoRestrictionIpAllowlist |
"" |
geoRestrictionIpBlocklist |
"" |
geoRestrictionLookupProvider |
NONE |
geoRestrictionLookupApiKey |
"" |
geoRestrictionLookupCacheTtl |
86400 |
geoRestrictionTrustCdnHeaders |
true |
geoRestrictionTrustKycCountry |
true |
geoRestrictionTrustProfileCountry |
false |
geoRestrictionBlockAnonymizedIps |
false |
geoRestrictionLogMode |
BLOCKED |
geoRestrictionLogRetentionDays |
365 |
geoRestrictionLogDedupeSeconds |
300 |
geoRestrictionNoticeTitle |
"" |
geoRestrictionNoticeMessage |
"" |
geoRestrictionContactEmail |
"" |
They are writable only from /admin/system/geo-restriction/settings, through
PUT /api/admin/system/geo-restriction/settings, which carries its own
permission edit.geo.restriction, its own validation, its own lockout preflight
and its own audit trail. That is the point of the refusal — an admin holding
plain edit.settings must not be able to switch off country blocking with a
hand-rolled payload. See
Geo restrictions and the access policy.
The 44 Super-Admin-only keys
PROTECTED_SETTING_KEYS (in backend/src/utils/protected-settings.ts) is the
list of keys whose change requires the caller's role to be literally named
Super Admin. Grouped by where they are edited:
| Where | Keys |
|---|---|
| Settings → Security (bot protection) | captchaProvider, captchaSecretKey |
| Settings → Wallet | withdrawAutoApprove (whose save also writes withdrawApproval, equally guarded and no longer editable on its own), walletTransferFee, walletTransferSpread, transfiIbanEnabled, transfiOnrampEnabled, transfiOfframpEnabled, transfiOnrampCustody, transfiOfframpCustody |
| Settings → Security | withdrawTwoFactorRequired, withdrawTwoFactorChallenge, withdrawTwoFactorAppAllowed, withdrawTwoFactorEmailAllowed, withdrawTwoFactorSmsAllowed |
| Settings → Features | kycFeatureEnforcement, dexEnabled |
| Web3 Trading console | dexAllowlistMode, dexKycRequired, dexDirectPoolsEnabled, dexPoolRiskAckRequired — and only there; Settings → Features answers 403 for the first two. It also accepts dexEnabled, which it renders read-only, because it cannot load at all while that switch is off |
| AI Support console | aiSupportEnabled, aiSupportAutonomy, aiSupportProvider, aiSupportDailyBudgetUsd, aiSupportMonthlyBudgetUsd, aiSupportAccountToolsEnabled, aiSupportOperationsEnabled, aiSupportWorkflowsEnabled, aiSupportWorkflowKeys, aiSupportAdminAssistantEnabled, aiSupportAdminActionsEnabled, aiSupportAdminActionKeys |
| Trading Bot console | tradingBotEnabled, tradingBotLiveTradingEnabled, tradingBotPlatformFeePercent, tradingBotTradingFeePercent |
| P2P settings | p2pTwoFactorRequired, p2pTwoFactorChallenge |
| This endpoint only | fxTradingExternalRoutingEnabled — the fx extension's own PUT rejects it outright |
| Legacy / vestigial | walletTransferFeePercentage (older name for the live fee key, still guarded), DEMO_STATUS and NEXT_PUBLIC_DEMO_STATUS (demo mode is read from the environment; no reader consults a settings row, so writing one changes nothing) |
The protected check runs after unchanged keys have been dropped, so an
ordinary admin with edit.settings can open the settings screen, change a
theme, and save the whole page without any of this firing. The instant their
payload changes one of the 44, the request is refused with
Only a Super Admin can change the "<key>" setting
and nothing in that save is written at all — the refusal is thrown while the payload is still being validated, before a single row is upserted. Their theme change is lost with it. There is no partial save.
showIf hides a field; it does not disable the setting
Nine fields carry a showIf condition. It is a render condition and
nothing else — the stored row is untouched when the condition goes false, and
every backend reader keeps reading it.
| Field | Shown only when |
|---|---|
twoFactorSmsStatus, twoFactorEmailStatus, twoFactorAppStatus |
twoFactorStatus is on |
withdrawTwoFactorRequired, withdrawTwoFactorChallenge |
twoFactorStatus is on |
withdrawTwoFactorAppAllowed |
twoFactorStatus and twoFactorAppStatus are on and one of the two withdrawal switches is |
withdrawTwoFactorEmailAllowed |
same, with twoFactorEmailStatus |
withdrawTwoFactorSmsAllowed |
same, with twoFactorSmsStatus |
captchaSiteKey, captchaSecretKey |
captchaProvider is turnstile, recaptcha or hcaptcha |
captchaScoreThreshold |
captchaProvider is recaptcha |
powCaptchaDifficulty |
captchaProvider is pow |
So an operator who turns on Require 2FA to Withdraw, then later turns
Two-Factor Authentication off, no longer sees the withdrawal switch — but
withdrawTwoFactorRequired is still "true" in the table, and turning 2FA back
on re-arms it with no further action. (In that particular case the platform
fails safe: withdraw-2fa.ts logs that the requirement is being ignored
while twoFactorStatus is off, rather than blocking every withdrawal.) The
general shape is what matters — a hidden field is not an off field, and the
only way to see the real value is to read the row.
The same mechanism applies on the geo policy screen, where
geoRestrictionLookupApiKey and geoRestrictionLookupCacheTtl are hidden while
the provider is NONE.
Two keys are withheld from the public settings feed
GET /api/settings is unauthenticated — it feeds the public site's branding
and feature flags — and it returns the settings table nearly whole. Every row it
carries is world-readable by anyone who can load your home page, whatever the
admin screen implies and whatever role is required to write it.
Two keys are filtered out of that response by name, in
backend/src/api/settings/index.get.ts:
| Key | Why |
|---|---|
geoRestrictionLookupApiKey |
Billable credential for the IP-geolocation provider. |
captchaSecretKey |
Verifies captcha tokens with the provider. Anyone holding it can mint valid verdicts against your site key. |
captchaSiteKey is deliberately not filtered: it is rendered into the page
by design and the widget cannot load without it.
These are two independent lists. PROTECTED_SETTING_KEYS decides who may
change a row; the filter above decides who may see it. A credential-shaped
setting needs an entry on both — adding one to the settings screen without adding
it here publishes it to every anonymous visitor of the site it protects.
Two caches, and why a hand-edited row reaches neither
CacheManager keeps settings in two places:
- A Redis blob under the
settingskey. - An in-memory
Mapinside every backend process — the API, each worker, the cron process.getSettings()reloads that Map only when it is empty; there is no TTL.
A write through the API clears both and broadcasts cache:invalidate so other
processes drop their Maps. A row you change with UPDATE settings SET ... does
neither, so a hand-edited value can be invisible for as long as the processes
stay up.
There is a backstop. The write path also stamps a __cacheVersion row, and a
process whose pub/sub is not working polls that row at most every 5 seconds and
reloads when it moves. A direct SQL edit does not move the stamp either, so
the backstop does not save you.
Both answer with settingsCount and extensionsCount so you can confirm the
reload actually happened. Run one after any direct database edit.
It lives in the settings table but is filtered out of the in-memory Map and
refused by the settings PUT with "__cacheVersion" is reserved for internal cache coordination and cannot be set. Writing it by hand either forces a
platform-wide reload or pins every other process on a version that never moves
again. Leave it alone.
What the PUT accepts and refuses
In order, per key:
settingsandextensionsas key names are skipped as structural noise.- The value is stringified —
null, the string"null"andundefinedall become""; an object isJSON.stringifyd. - Values over 1,000,000 characters are refused 400.
- A value identical to the stored one is dropped. This is what lets a full-page save pass the protected-key gate.
- A geo key is refused 403.
__cacheVersionis refused 400.- A protected key resolves the caller's role and refuses 403 unless it is
Super Admin. - A key with no existing row must match
^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,254}$or it is refused 400. Creating new keys is allowed — that is how a newly shipped setting gets its first row.
Two subtleties worth knowing:
- Key matching is case-insensitive, because the settings primary key uses a
case-insensitive MySQL collation.
WithdrawApprovalwould otherwise write straight through to the real row and past the protected list. Writes are applied using the stored casing, so a case-variant payload can never create a shadow row. - This route deliberately skips schema validation (
skipBodyValidation). The generic validator treats every undeclared key as untyped and coerces"1"/"on"/"yes"totrueand"0"/"off"/"no"tofalse— which used to store the literal texttruefor a numeric setting typed as1, andfalsefor one typed as0. Free-text settings had the same bug: a value ofNowas stored asfalse. Rows damaged before the fix are repaired bypnpm --filter backend repair:settings, described in Repair scripts.
Where the other settings live
The settings table holds far more than these 70 rows. Keys owned by an addon
or a specialised console are validated by that console's own PUT, which is why
they are absent here: trading (/admin/trading/settings), blog
(/admin/blog/settings), binary (/admin/finance/binary/settings), the DEX
console, the AI Support console and the geo policy screen each own their own
key set.
Values that must exist before the database does — credentials, ports, transport selection — are not settings at all. They are environment variables, and they are listed in Environment variables.