Supercommerce API Docs
Admin API

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 @RequirePermissions is AND-only — combining adminSetting and storeSetting on 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 own vendorSetting resource.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching permission.

Endpoint groupPermission
GET /admin/settings/admin/**adminSetting: read
PATCH /admin/settings/adminadminSetting: update
GET /admin/settings/store/**storeSetting: read
PATCH /admin/settings/storestoreSetting: update
GET /admin/settings/registry/**adminSetting: read
GET /admin/vendors/:vendorId/settings/**platformVendorSetting: read
PATCH /admin/vendors/:vendorId/settings/admin|storeplatformVendorSetting: 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

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR (unknown group / key, value fails registry validator)
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
500INTERNAL_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 200ScopeSettingsResponse.


GET /admin/settings/admin/:group — Single admin group

Required permission: adminSetting: read.

Path params

NameNotes
groupGroup identifier (e.g. reviews, payment)

Response 200GroupSettingsResponse (flat { key: value }).

Errors

StatusCodeWhen
400VALIDATION_ERRORUnknown 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 200ScopeSettingsResponse (full admin scope after the patch).

Errors

StatusCodeWhen
400VALIDATION_ERROREmpty 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 scope
  • GET /admin/settings/store/:group — single group
  • PATCH /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

NameNotes
vendorIdTarget vendor id

Response 200VendorScopeSettingsResponse.


GET /admin/vendors/:vendorId/settings/admin/:group — Single vendor admin group

Required permission: platformVendorSetting: read.

Response 200VendorGroupSettingsResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORUnknown 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 200VendorScopeSettingsResponse after the patch.

Errors

StatusCodeWhen
400VALIDATION_ERROREmpty 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 scope
  • GET /admin/vendors/:vendorId/settings/store/:group — single group
  • PATCH /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:

EndpointPermissionPurpose
GET /admin/settings/registry/*adminSetting: readGlobal 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: readPer-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, with currentValue from the platform settings table.
  • store — every platform store-scope setting in the requested groups, with currentValue from 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 flagged forAdmin: true (admin-controlled per-vendor knobs). currentValue is the registry default — never per-vendor, even when a vendorId would be available in the URL. This keeps adminSetting:read from leaking per-vendor data. Note: the former payouts vendor-settings group (commission rate, hold, notes) has moved to the dedicated vendor_payout_config table (GET/PUT /admin/vendors/:id/payout-config), so no vendor-registry group is currently forAdmin-flagged — the vendor bucket is empty here.
    • Per-vendor admin (/admin/vendors/:vendorId/settings/registry/fields): the full vendor surface (both forAdmin and vendor-writable entries) hydrated through the override → platform → default cascade for the target vendor. Matches PlatformVendorSettingsController, the existing values endpoint that lets admin write any vendor key with the forAdmin write guard bypassed. forAdmin entries carry isAdminOnly: true on the descriptor so the UI flags them visually rather than dropping them.

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 optionsSourceoptionsschema.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[]>;   // groupfields
  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

NameNotes
groupsComma-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

StatusCodeWhen
400VALIDATION_ERRORgroups missing or empty (route param validation)
403FORBIDDENCaller 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

NameNotes
vendorIdTarget vendor id

Response 200VendorRegistryGroupsResponse

{
  "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

StatusCodeWhen
403FORBIDDENCaller 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

NameNotes
vendorIdTarget vendor id

Query

NameNotes
groupsComma-separated group keys. At least one required. Same matching semantics as the global admin endpoint.

Response 200RegistryFieldsResponse (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

StatusCodeWhen
400VALIDATION_ERRORgroups missing or empty
403FORBIDDENCaller lacks platformVendorSetting: read

Unknown group keys are silently dropped — same tolerance as the global endpoint.


  • admin-rbac — gates every endpoint via adminSetting:* / storeSetting:* / platformVendorSetting:*. See admin-rbac.md. The registry-discovery surface inherits the same split: /admin/settings/registry/* is adminSetting:read; /admin/vendors/:vendorId/settings/registry/* is platformVendorSetting:read.
  • vendor — vendor-self read/write of the same per-vendor settings keys; the same DTO carries the readOnlyKeys[] list informing the vendor UI what they can't edit. The vendor-side registry discovery surface is at GET /vendor/settings/registry/* (see vendor/settings.md).
  • order — reads admin.payment.pending_timeout_hours for the stale-pending sweep. See order.md.
  • reviews — reads admin.reviews.* keys for moderation rules.

On this page