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.

10 min readUpdated 8 August 2026settings, keys, database, cache, super-admin, mobile

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.

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 / googlePlayLink ship 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:

  1. A Redis blob under the settings key.
  2. An in-memory Map inside 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.

Clears the cache and reloads settings and extensions from the database
Identical behaviour, as a DELETE

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

Writes changed settings. Body is a flat key/value map, not a wrapper object.

In order, per key:

  1. settings and extensions as key names are skipped as structural noise.
  2. The value is stringified — null, the string "null" and undefined all become ""; an object is JSON.stringifyd.
  3. Values over 1,000,000 characters are refused 400.
  4. A value identical to the stored one is dropped. This is what lets a full-page save pass the protected-key gate.
  5. A geo key is refused 403.
  6. __cacheVersion is refused 400.
  7. A protected key resolves the caller's role and refuses 403 unless it is Super Admin.
  8. 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. WithdrawApproval would 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" to true and "0"/"off"/"no" to false — which used to store the literal text true for a numeric setting typed as 1, and false for one typed as 0. Free-text settings had the same bug: a value of No was stored as false. Rows damaged before the fix are repaired by pnpm --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.