Settings Module — Admin
HTTP surface for the platform-wide settings store (admin + store scopes) and the platform-admin override surface for vendor-scoped settings (admin + store sub-scopes per vendor).…
HTTP surface for the platform-wide settings store (admin + store scopes) and the platform-admin override surface for vendor-scoped settings (admin + store sub-scopes per vendor). Reads are grouped (per group or full-scope); writes are bulk patches that the service validates against the in-code settings registry.
Source:
api-modules/settings/src/controllers/admin-settings.controller.ts,api-modules/settings/src/controllers/platform-vendor-settings.controller.ts.Routes are intentionally split per scope (rather than a single
/:scope) because@RequirePermissionsis AND-only — combiningadminSettingandstoreSettingon one decorator would require the caller to hold both, which is the opposite of what we want. Two fixed paths keep guard semantics clean. The vendor override surface uses a distinct permission resource (platformVendorSetting) so it can be granted to platform staff without also granting it to vendor users via their ownvendorSettingresource.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
GET /admin/settings/admin/** | adminSetting: read |
PATCH /admin/settings/admin | adminSetting: update |
GET /admin/settings/store/** | storeSetting: read |
PATCH /admin/settings/store | storeSetting: update |
GET /admin/settings/registry/** | adminSetting: read |
GET /admin/vendors/:vendorId/settings/** | platformVendorSetting: read |
PATCH /admin/vendors/:vendorId/settings/admin|store | platformVendorSetting: update |
GET /admin/vendors/:vendorId/settings/registry/** | platformVendorSetting: read |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR (unknown group / key, value fails registry validator) |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Settings registry
The in-code settings registry is the source of truth for which (scope, group, key) tuples exist and what each value validates as. Both the controller and the OpenAPI DTOs are intentionally permissive (Record<string, unknown>) because duplicating the registry's types here would drift on every new setting; the service rejects unknown groups / keys and invalid values with 400 VALIDATION_ERROR.
forAdmin flag
Some vendor settings keys are flagged forAdmin: true in the registry. The vendor-self surface refuses to write those keys; only the platform-admin override below can. The vendor read surface returns them inside a readOnlyKeys[] list so the vendor UI can render those fields disabled.
Domain types
Platform ScopeSettingsResponse
type ScopeSettingsResponse = Record<string /* group */, Record<string /* key */, unknown /* value */>>;Platform GroupSettingsResponse
type GroupSettingsResponse = Record<string /* key */, unknown /* value */>;Vendor VendorScopeSettingsResponse
type VendorScopeSettingsResponse = {
values: Record<string /* group */, Record<string /* key */, unknown>>;
readOnlyKeys: Record<string /* group */, string[]>;
};Vendor VendorGroupSettingsResponse
type VendorGroupSettingsResponse = {
values: Record<string /* key */, unknown>;
readOnlyKeys: string[];
};UpdateSettingsInput (bulk update body)
type UpdateSettingsInput = Record<string /* group */, Record<string /* key */, unknown>>;At least one group must be present.
Platform settings — admin scope
Base path: /admin/settings/admin. Permissions on adminSetting:*.
GET /admin/settings/admin — Full admin scope
Required permission: adminSetting: read. Returns every admin-scope setting, grouped.
Response 200 — ScopeSettingsResponse.
GET /admin/settings/admin/:group — Single admin group
Required permission: adminSetting: read.
Path params
| Name | Notes |
|---|---|
group | Group identifier (e.g. reviews, payment) |
Response 200 — GroupSettingsResponse (flat { key: value }).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Unknown group |
PATCH /admin/settings/admin — Bulk-update admin settings
Required permission: adminSetting: update.
Body
{
"reviews": {
"allow_vendor_approve": true,
"max_images_per_review": 10
},
"payment": {
"pending_timeout_hours": 24
}
}The service validates each (group, key) against the registry and rejects unknown groups/keys + invalid values with 400.
Response 200 — ScopeSettingsResponse (full admin scope after the patch).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty body, unknown group / key, value fails registry validator |
Platform settings — store scope
Base path: /admin/settings/store. Permissions on storeSetting:*.
The store scope shape mirrors the admin scope exactly — replace adminSetting with storeSetting and the path segment admin with store. Endpoints:
GET /admin/settings/store— full store scopeGET /admin/settings/store/:group— single groupPATCH /admin/settings/store— bulk update
Required permissions: storeSetting: read / storeSetting: update.
Platform-admin vendor settings override
Base path: /admin/vendors/:vendorId/settings. Permissions on platformVendorSetting:*. Target vendor is taken from the URL, not the active session. Writes bypass the forAdmin guard — platform staff can set any registered key including the admin-only ones.
GET /admin/vendors/:vendorId/settings/admin — Full vendor admin scope
Required permission: platformVendorSetting: read.
Path params
| Name | Notes |
|---|---|
vendorId | Target vendor id |
Response 200 — VendorScopeSettingsResponse.
GET /admin/vendors/:vendorId/settings/admin/:group — Single vendor admin group
Required permission: platformVendorSetting: read.
Response 200 — VendorGroupSettingsResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Unknown group |
PATCH /admin/vendors/:vendorId/settings/admin — Bulk-update vendor admin settings
Required permission: platformVendorSetting: update. Bypasses the forAdmin write guard.
Body — same shape as UpdateSettingsInput.
Response 200 — VendorScopeSettingsResponse after the patch.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty body, unknown group / key, value fails validator |
Vendor store scope (override)
Mirrors the vendor admin scope. Endpoints:
GET /admin/vendors/:vendorId/settings/store— full vendor store scopeGET /admin/vendors/:vendorId/settings/store/:group— single groupPATCH /admin/vendors/:vendorId/settings/store— bulk update
Required permissions: platformVendorSetting: read / platformVendorSetting: update. Same forAdmin bypass.
Registry discovery
A read-only introspection surface that exposes the settings registry itself: which groups exist, what fields each group contains, what type each field is, and what its current value is. The admin UI consumes this to render the settings page directly from the registry — adding a new setting (or group) takes no frontend deploy.
There are two admin-side discovery endpoints, split on the same permission boundary the values surface already uses:
| Endpoint | Permission | Purpose |
|---|---|---|
GET /admin/settings/registry/* | adminSetting: read | Global admin settings page. Platform admin + store fields with current values; shape of forAdmin: true vendor entries (no per-vendor hydration). |
GET /admin/vendors/:vendorId/settings/registry/* | platformVendorSetting: read | Per-vendor admin settings page. Same shape, but vendor.* is the full vendor surface hydrated through the cascade for the target vendor. |
Sources:
api-modules/settings/src/controllers/admin-settings-registry.controller.ts,api-modules/settings/src/controllers/platform-vendor-settings-registry.controller.ts.
Surfaces returned
Both endpoints return the same RegistryFieldsResponse shape:
admin— every platform admin-scope setting in the requested groups, withcurrentValuefrom the platform settings table.store— every platform store-scope setting in the requested groups, withcurrentValuefrom the platform settings table.vendor.admin/vendor.store— vendor-overridable entries in the requested groups. Filtering and hydration differ per endpoint:- Global admin (
/admin/settings/registry/fields): only entries flaggedforAdmin: true(admin-controlled per-vendor knobs).currentValueis the registry default — never per-vendor, even when avendorIdwould be available in the URL. This keepsadminSetting:readfrom leaking per-vendor data. Note: the formerpayoutsvendor-settings group (commission rate, hold, notes) has moved to the dedicatedvendor_payout_configtable (GET/PUT /admin/vendors/:id/payout-config), so no vendor-registry group is currentlyforAdmin-flagged — thevendorbucket is empty here. - Per-vendor admin (
/admin/vendors/:vendorId/settings/registry/fields): the full vendor surface (bothforAdminand vendor-writable entries) hydrated through the override → platform → default cascade for the target vendor. MatchesPlatformVendorSettingsController, the existing values endpoint that lets admin write any vendor key with theforAdminwrite guard bypassed.forAdminentries carryisAdminOnly: trueon the descriptor so the UI flags them visually rather than dropping them.
- Global admin (
Groups with no entries in a given surface are omitted from the response (not returned as empty arrays).
Domain types
SettingsGroupSummary
type SettingsGroupSummary = {
scope: "admin" | "store";
key: string; // group identifier
description: string; // copy for the admin tab/section heading
category?: "general" | "plugin"; // absent ⇒ "general"
pluginKey?: string; // present on plugin groups (e.g. "razorpay")
settingCount: number; // entries in this (scope, group)
};category lets the admin UI route groups: general groups appear in the main /settings list, while plugin groups (klaviyo, google_merchant, meta, payment.razorpay, payment.razorpay_magic, payment.phonepe) are excluded from it and surfaced under the Plugins sidebar area instead — each via its own plugin config page. pluginKey links a plugin group to its sidebar entry.
FieldDescriptor
type FieldDescriptor = {
key: string; // setting key within the group
description: string;
default: unknown; // registry default (null when unset)
currentValue: unknown; // hydrated value; falls back to default
isPublic?: true; // store-scope keys exposed via /store/settings
isAdminOnly?: true; // vendor keys flagged forAdmin in the registry
isSecret?: true; // sensitive keys (e.g. webhook secrets) — UI masks the input
schema: JsonSchema; // JSON Schema (draft 2020-12) for the value
ui?: SettingUiHint; // optional, display-only render hint (see below)
};
type SettingUiHint = {
// Forces the control instead of inferring from the JSON Schema.
control?: "text" | "textarea" | "number" | "boolean" | "select"
| "asset" | "object" | "structured-list" | "tags"
| "multi-select" | "toggle-matrix" | "column-select";
accept?: string; // asset: accepted file types, e.g. "image/*"
maxSizeMB?: number; // asset: max upload size
multiple?: boolean; // asset: value becomes a key array
placeholder?: string;
rows?: number; // textarea
itemFields?: SettingUiItemField[]; // structured-list rows
// select / multi-select / toggle-matrix choices.
options?: SettingUiOption[]; // static, beats schema.enum
optionsSource?: SettingUiOptionsSource; // loaded at render time, beats options
columns?: { key: string; label: string; description?: string }[]; // toggle-matrix / column-select
emptyLabel?: string; // shown instead of the control when nothing resolves
fullWidth?: boolean; // span both columns of the settings page grid
visibleWhen?: { key: string; equals: string | string[] };
};
type SettingUiItemField = {
key: string;
label?: string;
control?: string;
placeholder?: string;
options?: { value: string; label: string }[];
optionsSource?: SettingUiOptionsSource; // select: dynamic dropdown
accept?: string; // asset: accepted file types
maxSizeMB?: number; // asset: max upload size
span?: number; // share of the row, in twelfths (default 3)
};
type SettingUiOption = {
value: string;
label: string;
description?: string;
badge?: string; // short operator warning, e.g. "Add credentials"
disabledColumns?: string[]; // toggle-matrix cells that must stay off
disabledReason?: string;
href?: string; // admin route where the option is configured
};
type SettingUiOptionsSource = {
path: string; // GET path returning `data: Row[]`
valueKey?: string; // default "value"
labelKey?: string; // default "label"
descriptionKey?: string; // default "description"
};schema is produced by z.toJSONSchema(def.schema, { unrepresentable: "any" }) — when no ui hint is present the UI picks form controls off schema.type / schema.enum / schema.format and renders constraints (minimum, maxLength, etc.) from the same payload. Optional / nullable schemas surface as { anyOf: [...] }. Unrepresentable schemas degrade to {} so a single odd definition never crashes discovery.
ui is a display-only hint (it never affects validation — the zod schema stays authoritative). When present, the admin UI renders that control directly: asset → presigned UploadInput (the value is an object storage key), structured-list → a repeatable itemFields editor (e.g. schema.org breadcrumbs), textarea/tags/object, etc.
structured-list rows lay out on a 12-column grid, so itemFields[].span decides how the row divides — a long description can earn more room than a one-word badge, without the frontend knowing what any particular list holds. Spans should total 12; a field that declares none falls back to 3.
A settings group renders as a two-column grid, so fullWidth exists for controls that are wide by nature — a structured-list with several fields plus an upload per row, or a toggle-matrix with more than a couple of columns — which are otherwise squeezed into half the page while the other half sits empty. It applies at the xl breakpoint only; narrower viewports are single-column regardless.
Dynamic options (optionsSource)
Some settings only know their valid values at runtime — which payment gateways a deployment registered, which shipping providers are mounted. optionsSource names a GET path the admin UI calls while rendering the field, so the picker reflects reality without a frontend deploy.
The endpoint returns a flat array under data; each row needs at least value and label, with the remaining SettingUiOption fields optional. Resolution is optionsSource → options → schema.enum, and a failed fetch falls through to the static sources rather than leaving the control empty. Being a ui hint, it is display-only: the value is still validated against the zod schema on write, so an id that no longer resolves stays editable and saveable.
Feeds live with the module that owns the data and reuse adminSetting: read — the same permission that gates the settings page consuming them. See Payment Gateways for the two payment feeds.
toggle-matrix
A grid of switches: one row per resolved option, one column per ui.columns entry. The stored value is Record<columnKey, string[]> — one array of selected option values per column.
Used by admin.payment.enabled_providers ({ web: [...], app: [...] }), where each row is a registered gateway and each column a platform. An option may bar specific columns via disabledColumns (a gateway that cannot serve that platform), and those cells render fixed-off with disabledReason attached. Column headers toggle a whole column; a column left empty raises an inline warning.
column-select
The single-choice counterpart: one dropdown per ui.columns entry, all sharing one option list, stored as Record<columnKey, string>.
Used by store.checkout_payment.default_option, where the operator picks the preselected payment option separately for web and app. Splitting into one registry key per platform would have worked too, but then adding a platform means adding keys; a per-column value grows with columns. A column left empty means "this platform's first option".
enabled_providers and empty columns
Note that enabled_providers deliberately accepts an empty array per platform: settings save as one transaction, so rejecting an empty list would make unticking the last switch discard every other edit on the page — and a web-only deployment legitimately enables nothing for APP. Checkout still rejects a disabled or unknown provider at place-order.
RegistryFieldsResponse
type RegistryFieldsResponse = {
admin: Record<string, FieldDescriptor[]>; // group → fields
store: Record<string, FieldDescriptor[]>;
vendor: {
// Global admin endpoint: forAdmin-only entries, default currentValue.
// Per-vendor admin endpoint: full vendor surface, cascade-hydrated.
admin: Record<string, FieldDescriptor[]>;
store: Record<string, FieldDescriptor[]>;
};
};GET /admin/settings/registry/groups — Group catalog
Required permission: adminSetting: read. Returns the flat list of platform groups (admin + store scope) for the admin UI's left rail / tab strip. Vendor-overridable groups are intentionally absent — they describe a parallel surface and are reached via the fields endpoint (filtered to forAdmin) or /vendor/settings/registry/groups.
Response 200
{
"data": {
"groups": [
{ "scope": "admin", "key": "reviews", "description": "Review moderation policy …", "category": "general", "settingCount": 9 },
{ "scope": "admin", "key": "payment.razorpay", "description": "Razorpay gateway credentials …", "category": "plugin", "pluginKey": "razorpay", "settingCount": 3 },
{ "scope": "admin", "key": "payment.phonepe", "description": "PhonePe Standard Checkout v2 credentials …", "category": "plugin", "pluginKey": "phonepe", "settingCount": 10 },
{ "scope": "store", "key": "branding", "description": "Customer-facing brand identity …", "category": "general", "settingCount": 4 },
{ "scope": "store", "key": "checkout_payment", "description": "What each payment option looks like at checkout …", "category": "general", "settingCount": 2 },
{ "scope": "store", "key": "seo", "description": "Search, social, and structured-data settings …", "category": "general", "settingCount": 14 }
]
},
"message": "Success",
"statusCode": 200
}GET /admin/settings/registry/fields — Global admin field descriptors
Required permission: adminSetting: read. For the requested set of group keys, returns render-ready field descriptors split by surface, with currentValue hydrated from the persisted platform settings table (defaults filled in for keys that have never been set). vendor.* carries only forAdmin: true entries, and their currentValue is the registry default — there is no vendorId parameter on this endpoint; per-vendor hydration lives on the sibling endpoint below.
Query
| Name | Notes |
|---|---|
groups | Comma-separated group keys (reviews,payment,branding). At least one required. Match is by key alone — supplying shipping returns every entry across scopes whose key matches. |
Response 200
{
"data": {
"admin": {
"reviews": [
{
"key": "auto_approve",
"description": "When true, new reviews skip the pending state and publish immediately.",
"default": false,
"currentValue": false,
"schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "boolean" }
},
{
"key": "max_images_per_review",
"description": "Hard cap on review_images rows per review. Enforced at submit time.",
"default": 5,
"currentValue": 5,
"schema": { "type": "integer", "minimum": 0, "maximum": 20 }
}
]
},
"store": {
"branding": [
{
"key": "logo_url",
"description": "Absolute URL of the storefront logo image.",
"default": null,
"currentValue": null,
"isPublic": true,
"schema": { "anyOf": [{ "type": "string", "format": "uri" }, { "type": "null" }] }
}
]
},
"vendor": {
"admin": {},
"store": {}
}
},
"message": "Success",
"statusCode": 200
}The vendor.admin bucket is empty because no vendor-registry setting is currently forAdmin-flagged (the payout policy moved to vendor_payout_config).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | groups missing or empty (route param validation) |
| 403 | FORBIDDEN | Caller lacks adminSetting: read |
Unknown group keys are silently dropped (response returns 200 with empty buckets) — this lets the UI request the union of currently-rendered tabs without hard-failing when a tab name lags behind a registry rename.
GET /admin/vendors/:vendorId/settings/registry/groups — Per-vendor group catalog
Required permission: platformVendorSetting: read. Returns the flat list of vendor-overridable groups for the per-vendor admin settings page's tab strip. The catalog itself is global — vendorId is in the URL for permission gating + symmetry with the sibling values surface at /admin/vendors/:vendorId/settings/*.
Path params
| Name | Notes |
|---|---|
vendorId | Target vendor id |
Response 200 — VendorRegistryGroupsResponse
{
"data": {
"groups": [
{ "scope": "admin", "key": "shipping", "description": "Per-vendor shipping configuration …", "settingCount": 8 },
{ "scope": "admin", "key": "tax", "description": "Per-vendor tax provider selection …", "settingCount": 2 },
{ "scope": "admin", "key": "returns", "description": "Per-vendor return-window override …", "settingCount": 3 }
]
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Caller lacks platformVendorSetting: read |
GET /admin/vendors/:vendorId/settings/registry/fields — Per-vendor field descriptors
Required permission: platformVendorSetting: read. Same RegistryFieldsResponse shape as the global admin endpoint, but vendor.* is the full vendor surface — both forAdmin and vendor-writable entries — hydrated through the cascade for the target vendor. Used by the per-vendor admin settings page to render an editable form against the vendor's actual values.
Path params
| Name | Notes |
|---|---|
vendorId | Target vendor id |
Query
| Name | Notes |
|---|---|
groups | Comma-separated group keys. At least one required. Same matching semantics as the global admin endpoint. |
Response 200 — RegistryFieldsResponse (same TS type as the global endpoint)
{
"data": {
"admin": { /* … platform admin scope, current values from the platform table */ },
"store": { /* … platform store scope, current values from the platform table */ },
"vendor": {
"admin": {
"returns": [
{
"key": "window_days",
"description": "Per-vendor return-window override (days). NULL inherits admin.returns.window_days.",
"default": null,
"currentValue": 10,
"schema": { "anyOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }] }
}
]
},
"store": {}
}
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | groups missing or empty |
| 403 | FORBIDDEN | Caller lacks platformVendorSetting: read |
Unknown group keys are silently dropped — same tolerance as the global endpoint.
Related modules
admin-rbac— gates every endpoint viaadminSetting:*/storeSetting:*/platformVendorSetting:*. Seeadmin-rbac.md. The registry-discovery surface inherits the same split:/admin/settings/registry/*isadminSetting:read;/admin/vendors/:vendorId/settings/registry/*isplatformVendorSetting:read.vendor— vendor-self read/write of the same per-vendor settings keys; the same DTO carries thereadOnlyKeys[]list informing the vendor UI what they can't edit. The vendor-side registry discovery surface is atGET /vendor/settings/registry/*(seevendor/settings.md).order— readsadmin.payment.pending_timeout_hoursfor the stale-pending sweep. Seeorder.md.reviews— readsadmin.reviews.*keys for moderation rules.
Serviceability Module — Admin
Admin surface for the delivery deny-list — the pincodes the store cannot ship to, whether each one warns or blocks at checkout, and the message the shopper sees. Bulk add, bulk edit, bulk remove.
Shipping ClickPost Module — Admin surface
Admin-facing HTTP surface for the platform (central warehouse) ClickPost account — credentials, pickup address, courier map, parcel defaults, webhook secret, and the live active-courier lookup. This is the account used when an admin fulfills a sub-order with credentialSource=platform.