Skip to content

Global settings API

Deployment-wide runtime knobs are stored in a database-backed, admin-editable settings table. A write here takes effect immediately, with no service interruption: it invalidates the cached configuration across all workers, and the very next read of that setting sees the new value.

This is a global (not tenant-scoped) key/value store. Only a fixed, code-declared allowlist of keys can be set — this is not a free-form configuration blob; an unrecognised key is rejected. Each key validates its own shape:

Key Meaning Bounds
workflow_run_retention_days GDPR Art.5(1)(e) storage-limitation ceiling for workflow_run operational content (run state + trigger input). Terminal runs older than this window are deleted on the next retention sweep. Integer, 1–3650
trial_per_domain_daily How many free-trial sign-ups one email domain may start per rolling 24 h (see No-card free trial). A request over the ceiling is refused 429. Default (absent row) is 1000. This is a blunt floor against scripted mass-signup on a single obscure domain — it is not the primary anti-abuse control, because an attacker can rotate domains freely: the per-IP ceiling (5 / 24 h, not configurable), the one-trial-per-canonicalized-email dedup, and the disposable-domain blocklist are what actually bound abuse. Because a popular free-mail domain (e.g. gmail.com) accumulates legitimate sign-ups fast, set this generously; a value low enough to bite a real domain blocks every genuine sign-up from it for the rest of the window. Takes effect within about 60 s (settings cache). A malformed stored value falls back safe to the 1000 default — never to "unlimited" and never to zero. Integer, 1–1000000. Rejected → 400: a non-integer, NaN/±inf, or a value outside the range. The floor is 1, not 0, on purpose: a 0 ceiling would refuse every trial sign-up platform-wide, turning a tuning knob into an undocumented kill switch.
support_email Recipient for in-app Contact Support requests. A single email address, local@domain.tld, at most 254 bytes. Surrounding whitespace is trimmed. Rejected: an empty or non-string value, anything containing whitespace or a control character, more than one address, a display name, and a domain with no dot or with a leading/trailing/double dot.
contact_email Recipient for the in-app Contact us enquiry form (upgrade / enterprise / general contact) — a distinct mailbox from support_email (sales vs support). Never sent to the browser. Falls back to the built-in default mail@myrasecurity.com when unset. Same shape and validator as support_email (a single valid local@domain.tld, ≤ 254 bytes; whitespace trimmed; multiple addresses / display names / control characters / a dot-less or malformed domain rejected).
paid_signup_enabled Whether the direct paid-signup tier grid is offered platform-wide (see Self-serve sign-up). When on, new customers can pick a paid plan during signup and POST /admin/auth/signup/checkout is reachable; when off (the default) they start on the free trial and that route is refused 404. The same toggle also gates the signed-in in-app upgrade flow: when off, every upgrade CTA routes to the Billing & Payments contact form and the price calculator is hidden — no Stripe call is made from an upgrade CTA; when on, the in-app paid upgrade flow (calculator → checkout) is shown. The trial-to-paid conversion (POST /admin/v1/billing/checkout) itself is unaffected either way. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
plan_model_copy_enabled Kill switch for the plan-aware model taglines in the model picker (AGF-2930; see Tagline resolution). Ships off (an absent or false value). When set to true, GET /admin/v1/models shows a self-serve workspace short lines written for the models of its plan (tagline_source: "plan_copy"); when false, the generated copy is skipped entirely and every row shows its catalog tagline or the display name only — today's behaviour. Takes effect within about 60 s (settings cache). Set it with PUT /admin/v1/settings/plan_model_copy_enabled (no console toggle in v1). A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number, null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off) — this is a display feature, not an access control.
avv_current_version The server-pinned current AVV (Art. 28 data-processing agreement) version registrants must accept (replaces a retired deployment setting). Read per request by the signup flow: when set (or defaulted), POST /admin/auth/signup/request requires avv_accepted: true and snapshots this server value onto the intent → consent_record. Default (absent/empty row) is 2026-08-21 ⇒ AVV acceptance is ACTIVE. A malformed stored value read back from the DB falls back safe to the code default (never dormant) and logs. Clearing the key cannot disable acceptance — it restores the active default. Set it from the platform-admin Feature Flags screen or here directly. A string version token: 1–32 characters of letters, digits, ., -, _ or : (e.g. 2026-08-21). Surrounding whitespace is trimmed. Rejected → 400: a non-string, empty/whitespace-only, over 32 bytes, or any other character (space, /, !, control bytes, CR/LF).
wallet_enabled The platform-wide on/off switch for the wallet. The sole gate: the wallet is offered as soon as this is true — the balance cap has been retired and the top-up amounts are configured on the product catalog's wallet_topup row, so no companion keys are required. Changing it also drops the rendered public/tenant catalog feed bodies (the feed advertises top-ups only while the wallet is on; other server nodes follow within 60 s, browsers within their 60 s cache). Default (absent) is off. Set it from the platform admin Feature Flags screen (Organisations) or here directly. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
wallet_autotopup_tax Attaches a Stripe Tax calculation to every automatic wallet top-up (an off-session charge; manual top-ups are taxed by Checkout already), so Stripe records the tax transaction and reverses it on refunds — see Billing — Wallet. Default (absent) is off. Switch it on only once every server runs the release that supports it, and off before any rollback (internal billing runbook). A JSON boolean — same rules as wallet_enabled.
reactivation_emails_enabled The platform-wide on/off switch for the member-reactivation e-mails. Off means no reactivation mail is sent anywhere, whatever the tenants' own settings — this is the incident brake. The feature shipped dark (an absent row reads as off); migration 0337 set it to true in every deployment on 2026-09-29, so it is on unless a platform admin turned it off. On means the sweep runs for every tenant whose tri-state resolves to on. Set it from the platform admin Feature Flags screen (Organisations) or here directly. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
signup_reminder_enabled The platform-wide on/off switch for the signup-abandonment reminder. Off means no reminder is sent anywhere. The feature shipped dark (an absent row reads as off); migration 0354 set it to true after the DPO/legal sign-off, so it is on wherever this release is deployed (integration already; production when the release lands) unless a platform admin turned it off. Set it from the platform admin Feature Flags screen (Organisations) or here directly. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
signup_reminder_daily_cap The platform-wide ceiling on signup-abandonment reminder e-mails per Berlin calendar day (the per-tick cap of the sweep is 20). A JSON integer between 1 and 1000. Default (absent) is 100. Rejected → 400: a non-integer or a value outside the range.
reactivation_sender_name The human sender display name of every member-reactivation e-mail — e.g. Anna von Myra AI. The sending address never changes (the platform's no-reply mailbox); replies reach the support mailbox through Reply-To = support_email. Unset → the tenant's own product name (white-label safe). Platform-wide: a white-label tenant's members see this name too. A string of 1–64 bytes after trimming, valid UTF-8, no control characters and none of < > " \ @ or =? — the from-spoofing shapes. Rejected → 400, nothing stored (never coerced): anything else, a non-string, over 64 bytes. Exception to the empty-value rule of every other key: "" (or whitespace only) is the explicit clear — it is stored and read as "unset", so the product name applies again; there is no other way to un-freeze a name, since keys cannot be deleted.
reactivation_daily_cap The maximum number of reactivation e-mails per tenant per Berlin calendar day. The sweep additionally sends at most 20 mails per run per gateway node (three runs fall inside a send window), so the practical ceiling is about 60 a day per node regardless of this value; this per-tenant cap is re-checked in the database before every send, so it holds across nodes. Absent → 50. Integer, 1–1000.
oauth_login_enabled The platform-wide on/off switch for Google & Microsoft quick sign-in / registration. One of two gates: a provider's button appears, and its OAuth endpoints answer, only while this is true and that provider's OAuth credentials are provisioned. Default (absent) is off, so the feature ships dark — turning it on is a deliberate go-live act, done alongside provisioning the credentials. Set it from the platform admin Feature Flags screen (Organisations) or here directly. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
feedback_triage_enabled The platform-wide on/off switch for automated feedback triage — a background sweep that classifies each new app-feedback and chat-feedback submission (category, sentiment, severity, confidence) with the platform's own model. Off (the default) means no feedback is classified anywhere; the feature ships dark and this is the incident brake. On means the sweep runs (one node at a time; bounded per run). It never sends a response, resolves an item, or creates a ticket — it only classifies and records a status. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
feedback_triage_model The model id the feedback-triage classifier dispatches to (dogfooding the platform's own inference fleet). Unset → the platform's default chat model. A string of at most 64 bytes. "" reads as unset → the code default. Rejected → 400: a non-string or a value over 64 bytes.
feedback_triage_min_confidence The minimum AI confidence (percent) required to auto-triage a feedback item; below it, the item is routed to a human (needs_human). High/critical severity and any un-parseable model answer ALWAYS route to a human regardless of this value — the brand safety net. Absent → 70. Integer, 0–100.
feedback_response_enabled The platform-wide on/off switch for automated feedback responses — a background sweep that, for each already-triaged item, sends the user a deterministic acknowledgment (all categories) plus, for a high-confidence question, an AI-drafted answer, delivered in-app and by e-mail. Off (the default) means no response is ever sent; the feature ships dark and this is the incident brake. Requires feedback_triage_enabled to be on to have items to respond to. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number, null, or an absent value → 400 (never coerced). An absent row reads as false (off).
feedback_response_model The model id used to draft the substantive answer to a high-confidence question (dogfooding the platform's own inference fleet). Unset → the platform's default chat model. A string of at most 64 bytes. "" reads as unset → the code default. Rejected → 400: a non-string or a value over 64 bytes.
feedback_response_min_confidence The minimum AI confidence (percent) required to auto-answer a question; below it the question routes to a human with no answer sent. A higher bar than triage (substantively answering is riskier than filing). High/critical severity ALWAYS routes to a human regardless of this value. Absent → 80. Integer, 0–100.
feedback_response_user_daily_cap The per-user cap on auto-response e-mails per rolling day. Over the cap a user is still answered in-app but no e-mail is sent (spam/abuse bound). Absent → 5. Integer, 1–1000.
feedback_cs_enabled The platform-wide on/off switch for the CS-automation sweep (runs after triage): auto-resolve praise, auto-create/link a YouTrack ticket for an actionable high-confidence bug/feature, and route the rest to the human queue. Off (the default) means no CS automation runs; the feature ships dark. It also needs the YouTrack integration configured (PUT /admin/v1/feedback/youtrack) — without a token, actionable items fail closed to needs_human. A JSON boolean — true/false. The strings "true"/"false", a number, null, an object, or an absent value → 400 (never coerced). Absent reads as false.
feedback_cs_min_confidence The minimum AI confidence (percent) required to auto-create a ticket for a bug/feature; below it the item goes to a human. A higher bar than triage (ticketing is a stronger action). High/critical severity always routes to a human regardless. Absent → 80. Integer, 0–100.
feedback_cs_max_tickets_per_sweep The rate cap on new YouTrack tickets auto-created per sweep (links to existing tickets and auto-resolves are uncapped). A backstop against a spam/mass-feedback burst minting a flood of tickets; a capped item is deferred to a later sweep, not escalated. Absent → 20. Integer, 0–1000.
active_mcp_connectors The active subset of the MCP connector directory shown to non-platform-admins. GET /admin/v1/mcp/catalog filters to these slugs for a non-platform-admin (active-first, then alphabetical); a platform admin sees every entry flagged active. Absent → the go-live default ["gmail","slack"]. A JSON array of known catalog slugs. A tagged empty [] is accepted (none active). Rejected → 400: a JSON object/scalar/null (not an array), an unknown slug, a non-string element, or more than the catalog size. Deduplicated; persisted canonically.
active_chat_platforms The active subset of the chat-integration platform picker shown to non-platform-admins. GET /admin/v1/chat-bridge/platforms filters to these names for a non-platform-admin; a platform admin sees all flagged. Absent → the go-live default ["mattermost"]. A JSON array of known platform names (mattermost, slack, teams). Empty [] accepted. Rejected → 400: a non-array, an unknown platform, a non-string element.
trial_linkup_api_key The Myra platform Linkup API key applied to every keyless Linkup gateway — all plans (trials, paid self-serve, admin-provisioned enterprise/POC organisations, and a trial that converted to paid). The key name keeps its historical trial_ prefix (no rename); since AGF-2874 it is no longer trial-only. Every new production gateway is created with a keyless Linkup web_search block (unless the creator supplies its own web_search), and the key is injected into such a block at read time — never stored per gateway, never exported, never returned to a client. A gateway with its own web_search.api_key (BYOK) or another provider is never touched. It is a low-value search key, not a credential: a plain setting, returned verbatim on GET and admin-editable live. Absent/unset while at least one production gateway carries a keyless Linkup block → those gateways have no web search (fail closed), the Health dashboard flag web_search_platform_key_missing is true, the gateway logs ERR every 10 minutes and a [platform_alert] fires (at most every 6 h); the post-deploy release check asserts the row exists with a value longer than 20 characters. Set it from the platform-admin Feature Flags screen. A newly set key is eventually effective (≤ ~60 s — the per-gateway folded-config cache TTL), not instant. Seeded automatically at boot (migration 0345, AGF-2874): when the row is absent or empty, the most-used well-formed Linkup api_key found in the instance's own gateway.config.web_search rows is copied in, so a fresh deployment needs no manual step; a non-empty value set here or in the console is never overwritten by the migration. If no gateway carries a usable key, the row stays absent and the health flag web_search_platform_key_missing reports it. A string API key: non-empty, at most 512 bytes, containing no whitespace and no control characters (a real Linkup key is a whitespace-free token; whitespace/CR-LF is rejected outright because the value becomes an outbound Authorization header). Rejected → 400: a non-string, empty, whitespace-anywhere (incl. CR/LF/tab), a control byte, or over 512 bytes.
copilot_entra_aud_allow The accepted Entra token audience(s) for the M365-Copilot document-AI lane (/copilot/v1) — the aud claim the inbound access-token validator (middleware.entra_auth) requires. Set to your Myra app registration's Application ID URI(s): api://{guid} (v2) and/or the bare {guid} (v1). Both this and copilot_entra_azp_allow must be set or the lane is dormant (every token is rejected 401) — this is the go-live switch, and the feature ships dark. Not a secret; returned verbatim on GET. A JSON array of strings. Each entry is non-empty, ≤ 255 bytes, with no whitespace (an App ID URI or GUID). A tagged empty [] is accepted (lane off). Rejected → 400: a non-array, a non-string element, a blank/oversized/whitespace-bearing entry, or more than 8 entries. Deduplicated; persisted canonically.
copilot_entra_azp_allow The accepted Entra authorized-party client id(s) for the M365-Copilot document-AI lane — the azp/appid claim the validator requires (the one Myra multi-tenant Entra app's client id). Companion to copilot_entra_aud_allow; both must be set for the lane to accept any token. Not a secret. A JSON array of directory GUID strings (8-4-4-4-12). A tagged empty [] is accepted (lane off). Rejected → 400: a non-array, a non-GUID element, a non-string element, or more than 8 entries. Deduplicated; persisted canonically.
copilot_entra_scope_required Optional. The delegated scope the Copilot access token must carry (its scp VALUE, not merely that scp is present). Unset → presence-only (any delegated scope from the Myra app is accepted — the aud/azp gate already fences to the one app). Set it to the app's Copilot delegated scope (e.g. access_as_user) to reject a token minted for a lesser scope. Read by middleware.entra_auth; a token whose scp lacks it is 403. A single string scope token: non-empty, ≤ 255 bytes, no whitespace (space delimits the scp claim). Rejected → 400: a non-string, empty, over-length, or a value containing whitespace.
direct_entra_aud_allow The accepted Entra token audience(s) for the direct document-AI lane (/direct/v1) used by the Office.js Outlook add-in. The aud claim middleware.entra_auth requires — set to the add-in's own Entra app registration Application ID URI (api://{host}/{client-id}), a different app from the Copilot lane. Must NOT overlap copilot_entra_aud_allow (a shared aud would let a Copilot server-to-server token onto this browser lane) — the write API rejects an overlapping value 400. Unset (or azp unset) → the direct lane is dormant. Not a secret. A JSON array of strings (App ID URI or GUID; non-empty, ≤ 255 bytes, no whitespace). Tagged empty [] accepted (lane off). Rejected → 400: a non-array, non-string/blank/oversized entry, more than 8 entries, or any entry already present in copilot_entra_aud_allow. Deduplicated; persisted canonically.
direct_entra_azp_allow The accepted Entra authorized-party client id(s) for the direct lane — the azp/appid claim. For Office SSO this is the Microsoft Office host client id(s) (host/platform-dependent), NOT the add-in's app id; these may legitimately overlap the Copilot lane (they are Microsoft's), so no overlap check is applied. Companion to direct_entra_aud_allow; both must be set for the lane to accept a token. Not a secret. A JSON array of directory GUID strings (8-4-4-4-12). Tagged empty [] accepted (lane off). Rejected → 400: a non-array, a non-GUID/non-string element, or more than 8 entries. Deduplicated; persisted canonically.
direct_entra_scope_required Optional. The delegated scope the direct-lane token must carry (its scp VALUE). Independent of copilot_entra_scope_required — a copilot scope requirement does NOT apply to the direct lane and vice versa. Unset → presence-only. A token whose scp lacks it is 403. A single string scope token: non-empty, ≤ 255 bytes, no whitespace. Rejected → 400: a non-string, empty, over-length, or whitespace-bearing value.
sf_sync_webhook_url The n8n webhook URL for the AI-Workspace → Salesforce lead sync. The sync is a no-op until both this and sf_sync_webhook_secret are set (the deliberate go-live act — do not enable before the DPA/privacy review and the account-deletion erasure path are in place; see the sync page). Returned verbatim on GET. A string URL that must start with https:// (it carries a bearer secret) — no whitespace, no control bytes, ≤ 2048 bytes. Rejected → 400: a non-string, empty, http:// or any non-https, a host-less value, whitespace, or a control byte.
sf_sync_webhook_secret The Authorization-header secret n8n requires for the lead sync. Stored encrypted at rest (AES-256 + HMAC, the platform master key) and write-only: GET never returns it — it reports is_set: true with an empty value, and the audit log records a redacted marker, never the value. The sync module decrypts it only to place it in the outbound Authorization header. A string: non-empty, ≤ 1024 bytes, no whitespace and no control bytes (the value becomes an Authorization header — CR/LF/tab or a control byte is a header-injection sink, rejected at the boundary and re-checked after decrypt). Rejected → 400: a non-string, empty, whitespace-anywhere, a control byte, or over 1024 bytes.
sf_sync_sandbox Routes the lead sync to the Salesforce Sandbox (true) rather than production (false/absent). The flag is sent in the payload; n8n uses it to pick the Salesforce target. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number, null, or an absent value → 400 (never coerced). An absent row reads as false (production).
connect_ox_allowed_origins The allow-list of browser Origins permitted to complete the Connect-OX handshake (POST /admin/v1/me/connect/ox) — the exact https:// Origin(s) of the Open-Xchange site(s) that embed the Connect button. Default (absent) is [] ⇒ the handshake is dormant (every mint request → 403, fail closed). The mint endpoint re-validates each stored entry AND requires an exact match against the request's origin, so a substring/prefix or a non-canonical entry never authorizes a token. Enter each origin canonically — the value is stored verbatim, not normalised, so a non-canonical entry would simply never match. A JSON array of canonical https origins. Each entry: https://host[:port], lowercase scheme+host, ASCII host (an IDN must be pre-encoded as punycode xn--…), no path/query/fragment/userinfo/@, an optional port that is not :443, ≤ 200 bytes. A tagged empty [] is accepted (handshake off). Rejected → 400: a non-array, a non-string element, or any entry that is not a canonical origin (uppercase, http://, a trailing/leading/double dot, whitespace, *, null, a bare host, :443, a path, or a non-ASCII byte); more than 50 entries. Deduplicated; persisted canonically.
eu_captcha_enabled The platform-wide on/off switch for EU Captcha on the registration form (AGF-2969). Default (absent) is off. The feature is a no-op until an admin turns this on and sets eu_captcha_sitekey and the server-only secret (PUT /admin/v1/eu-captcha/secret) and the registration widget + verify flow ship (AGF-2986/2987) — so it ships dark. A malformed stored value reads off (core.settings.get_bool), never coerced on. A JSON boolean — true or false. Rejected: anything that is not a boolean, including the strings "true"/"false", a number (1/0), null, an object, or an absent value → 400 (never coerced). An absent row reads as false (off).
eu_captcha_sitekey The public EU Captcha sitekey shown on the registration page (AGF-2969) — a UUID-shaped token, safe to embed in the page (the matching server-only secret is set separately via PUT /admin/v1/eu-captcha/secret and is never sent to the browser). Unset → the feature is off. A string restricted to an HTML-safe allowlist — letters, digits, ., -, _ (e.g. a UUID) — at most 128 bytes. Rejected → 400: a non-string, empty, over 128 bytes, or any other character (whitespace, a control byte, or an HTML metacharacter such as < > & " ' — the allowlist exists so the value can never become a script-injection sink on the registration page).

Until an admin writes a row for a key, the code's own default applies — for workflow_run_retention_days that is the deployment default set by your operator; for support_email it is the built-in support mailbox. Either way the cutover is a pure addition, not a breaking change. Once a row exists, the database value wins permanently.

Note that an empty value is rejected (400) rather than stored for every key, and there is no endpoint to delete a key — so support_email cannot be blanked to disable support email; point it at a different mailbox instead. A stored empty value (were one ever written directly to the database) would also read as "no row" and fall back to the built-in default, as would a brief database outage.

Secret settings. A key marked secret (today: sf_sync_webhook_secret) is stored encrypted at rest with the platform master key and is write-only: PUT validates and encrypts the value, and both GET and the PUT response return is_set with an empty value instead of the secret; the audit log records a redacted marker. There is no way to read a secret back through the API — rotate it by writing a new value. (As a safeguard, GET also redacts any stored value that is a master-key ciphertext even if its key is not in the allowlist.)

Base URL: https://<your-gateway-host>/admin/v1

Every endpoint requires the platform admin role; a lower role is 403, an unauthenticated caller 401. Access is re-gated server-side — the client is never the authorization boundary.

List settings

GET /admin/v1/settings

Returns every row currently stored (keys with no row are simply absent — the deployment's env-derived default applies):

[
  {
    "key": "workflow_run_retention_days",
    "value": 14,
    "updated_at": 1750000000,
    "updated_by": "<admin-user-id>"
  },
  {
    "key": "trial_linkup_api_key",
    "value": "lk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "updated_at": 1750000100,
    "updated_by": "<admin-user-id>"
  }
]

Every row's value is returned verbatim (there is no masked / write-only key).

Updating a setting

PUT /admin/v1/settings/{key}

Upserts the named key. The change is audited (settings / update in the audit log, entity id = the key) and takes effect on the next read that follows the write (the config cache is invalidated on success).

Accepted body

{ "value": 14 }

value's accepted shape depends on the key — workflow_run_retention_days requires an integer in 1–3650; support_email requires a single valid email address ({ "value": "aiws.support@myrasecurity.com" }); paid_signup_enabled, wallet_enabled, wallet_autotopup_tax, oauth_login_enabled, reactivation_emails_enabled and feedback_triage_enabled each require a JSON boolean ({ "value": true } — the string "true" or a number is rejected, never coerced); trial_per_domain_daily requires an integer in 1–1000000; reactivation_daily_cap requires an integer in 1–1000; feedback_triage_min_confidence, feedback_response_min_confidence, feedback_cs_min_confidence each require an integer in 0–100; feedback_response_user_daily_cap requires an integer in 1–1000; feedback_cs_max_tickets_per_sweep requires an integer in 0–1000; feedback_response_enabled, feedback_cs_enabled each require a JSON boolean; feedback_triage_model, feedback_response_model each require a string of at most 64 bytes; avv_current_version requires a 1–32-char version string in [A-Za-z0-9 . - _ :] ({ "value": "2026-08-21" }).

Response 200

{ "ok": true, "key": "workflow_run_retention_days", "value": 14 }

value is echoed back in its coerced form — the value after the key's validator has normalised it (for example a monetary cap is returned as a two-decimal string), which may differ from the raw value sent in the request body.

Rejected (fail closed — nothing is persisted)

  • {key} is not one of the known settings keys → 404.
  • value is missing, null, or fails that key's validator → 400. For an integer key that means non-numeric, fractional, NaN/±infinity or out of bounds; for support_email it means a non-string, or anything that is not a single well-formed address (multiple recipients, a display name, embedded whitespace or control characters, a domain without a dot or with a leading/trailing/double dot, or over 254 bytes); for paid_signup_enabled, wallet_enabled, wallet_autotopup_tax and oauth_login_enabled it means anything that is not a JSON boolean — the strings "true"/"false", a number, an object — so a truthy-looking value can never be coerced into enabling the flag; for trial_linkup_api_key it means a non-string, an empty value, any whitespace (including a CR/LF or tab), a control byte, or a value over 512 bytes; for avv_current_version it means a non-string, an empty/whitespace-only value, a value over 32 bytes, or any character outside [A-Za-z0-9 . - _ :]; for eu_captcha_enabled it means anything that is not a JSON boolean (as above); for eu_captcha_sitekey it means a non-string, an empty value, a value over 128 bytes, or any character outside the HTML-safe allowlist [A-Za-z0-9 . - _] (whitespace, control bytes, and HTML metacharacters < > & " ' are all rejected so the public sitekey can never become a script-injection sink). The server-only eu_captcha_secret is not settable here — it has its own endpoint; a PUT /admin/v1/settings/eu_captcha_secret is an unknown key → 404.
  • a non-platform-admin caller → 403; unauthenticated → 401.
  • a database write failure → 500.