Description
Purpose and use
Items provide reusable reference values for forms, configuration screens, onboarding steps, and operational pick lists. They help the bank keep multilingual labels and controlled choices consistent across modules.
Who uses this. Configurator administrators, operations owners, product teams, and compliance teams use item sets when defining allowed values for customer, KYB, product, and workflow screens.
How it works. A set groups related items, and each item can carry localized labels, ordering, active status, metadata, and structure rules. Screens then reuse the set instead of hard-coding their own choices.
What users do. Users define item sets, add or retire values, translate labels, and review which values are available in a workflow.
Outcomes and side effects. Item changes can alter selectable values in forms and filters. Existing records keep their stored values, while future user choices follow the updated set.
Related manuals: KYB, Products, I18n Admin, App config admin.
Overview
The Items API provides functionality for managing generic items and set items in the system. It supports:
- Multiple item types
- Multilingual title and description for generic items
- Set items for record associations
- Active/inactive status
- System items (protected from deletion)
- Custom sorting order
- RBAC-based access control
Core Concepts
Item Structure
Each item consists of:
- ID: UUID identifier for the item
- Type: Categorizes the item (e.g., "product", "service", "category")
- Name: Unique identifier within its type
- Title: Multilingual title in supported languages
- Description: Multilingual description in supported languages
- Sort Order: Custom ordering for display purposes
- Active Status: Whether the item is active or not
- System: Whether the item is a system item (system items cannot be deleted)
Set Item Structure
Each set item consists of:
- ID: UUID identifier for the set item
- Record ID: ID of the associated record
- Record Type: Type of the associated record
- Type: Categorizes the set item
- Name: Name of the set item
- Active Status: Whether the item is active or not
- Created/Modified: Timestamps and user tracking
Multilingual Support
Title and description fields support multiple languages. The response language is determined by the Accept-Language header:
{
"title": {
"en": "English Title",
"de": "Deutscher Titel",
"fr": "Titre Français",
"it": "Titolo Italiano",
"cz": "Český název"
}
}API Endpoints
Endpoints that return localized fields (GET /v1/items and GET /v1/items/{id} — the only two
handlers that read the header) accept Accept-Language. Resolution is four steps: exact match on
the stored locale map; the base language, so en-US falls to en; the app-config
default_language, which equals "en" only while that key is unset; and finally, if none of
those keys is in the map, the first key in alphabetical order of whatever the item stores.
An item holding only de and fr, requested as en or with no header, therefore returns the
German text in title_loc with nothing to signal the substitution. title_loc is never empty
for a populated item, so its presence does not mean the locale matched.
Create Set Item
POST {{base_url}}/v1/items/set
Create a new set item. The server will generate a UUID for the new set item.
Request Body
{
"record_id": "string", // ID of the record this set item is associated with
"record_type": "string", // Type of the record
"type": "string", // Type of the set item
"name": "string" // Name of the set item
}Response
{
"id": "uuid-string",
"record_id": "string",
"record_type": "string",
"type": "string",
"name": "string",
"created_at": "2025-02-24T12:57:34+01:00",
"created_by": "user-uuid",
"modified_at": "2025-02-24T12:57:34+01:00",
"modified_by": "user-uuid",
"active": true,
"metadata": {}
}Create Item
POST {{base_url}}/v1/items
Create a new item. The server will generate a UUID for the new item.
Request Body:
{
"type": "product",
"name": "premium-savings-account",
"title": {
"en": "Premium Savings Account",
"de": "Premium-Sparkonto",
"fr": "Compte d'épargne Premium",
"it": "Conto di Risparmio Premium",
"cz": "Prémiový spořicí účet"
},
"description": {
"en": "High-yield savings account with premium benefits",
"de": "Hochverzinsliches Sparkonto mit Premium-Vorteilen",
"fr": "Compte d'épargne à haut rendement avec avantages premium",
"it": "Conto di risparmio ad alto rendimento con vantaggi premium",
"cz": "Výnosný spořicí účet s prémiovými výhodami"
},
"sort_order": 1,
"active": true,
"system": false
}Response:
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"type": "product",
"name": "premium-savings-account",
"title": {
"en": "Premium Savings Account"
},
"description": {
"en": "High-yield savings account with premium benefits"
},
"sort_order": 1,
"active": true,
"system": false,
"created_at": "2025-02-23T16:14:05Z",
"modified_at": "2025-02-23T16:14:05Z"
}Get Item
GET {{base_url}}/v1/items/{id}
Retrieve an item by its UUID.
Update Item
PUT {{base_url}}/v1/items/{id}
Update an existing item by its UUID. Only the fields that need to be updated should be included in the request.
The three states differ between metadata and the locale maps. metadata is a pointer:
absent leaves it alone, null clears it. title and description are plain maps and the service
branches on nil rather than on presence, so absent and null are both no-ops — while {} is
non-nil and marshals to {}. {"title": {}} answers 200 and replaces every stored locale
with an empty map, and the previous value is not recoverable from the response.
Request Body:
{
"title": {
"en": "Premium Savings Account V2"
},
"active": false
}Delete Item
DELETE {{base_url}}/v1/items/{id}
Delete an item by its UUID.
The delete is permanent. models.Item embeds models.BaseModel, which carries no
gorm.DeletedAt, so this issues a real DELETE FROM misc.items. The row is gone — this is not
the logical active = false deletion used elsewhere in the platform, and it is not recoverable.
Note: System items (where system is true) cannot be deleted; the attempt answers 400
items_m.system_item_cannot_be_deleted. That protection is not durable, though:
PUT /v1/items/{id} accepts "system": false, so a caller with update rights can clear the flag
and then delete the item.
List Items
GET {{base_url}}/v1/items
List all items with pagination support.
Note: an unknown search field is answered 500 here rather than the 400 the same mistake
gets on GET /v1/items/set — that one differs by route. The missing modification-time column is
not route-specific: search.updated_at is a 500 on both lists, and there is no spelling that
works on either. See Error Codes.
Response:
{
"data": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"type": "product",
"name": "premium-savings-account",
"title": {
"en": "Premium Savings Account"
},
"description": {
"en": "High-yield savings account with premium benefits"
},
"sort_order": 1,
"active": true,
"system": false,
"created_at": "2025-02-23T16:14:05Z",
"modified_at": "2025-02-23T16:14:05Z"
}
],
"total": 1,
"total_unfiltered": 1,
"has_more": false
}Get Distinct Item Types
GET {{base_url}}/v1/items/types/distinct
Retrieve distinct item types with their counts. This endpoint returns a list of all unique item types and how many active items exist for each type.
Response:
[
{
"type": "mfa",
"count": 3
},
{
"type": "credential",
"count": 5
},
{
"type": "address",
"count": 2
}
]Get Set Items
GET {{base_url}}/v1/items/set
Retrieve a list of set items with support for filtering, sorting, pagination, and stacking.
Query Parameters
search.* (optional): Search parameters using dot-notation where the part after search. is the field name (e.g., search.type, search.name, search.active).
sort (optional): Sort field. Prefix with - for descending order (e.g., sort=-created_at, sort=name).
search_text (and its alias search) is ignored here: both land in Parameters.SearchText,
which only ExtractTextSearch reads, and the set-item count and fetch never call it. The request
succeeds and comes back unfiltered. The spelling that does work on this route is search._text=...,
which travels inside the search map — _text is the reserved field name.
limit(optional): Number of items per page. Out-of-range values are rewritten, not rejected: above 100 it is clamped to 100 silently,0and negatives other than-1fall back to 10, and-1is a counts-only mode that returnstotal/total_unfilteredwithdataempty.offset(optional): Starting position for pagination. A negative value is treated as 0.stack(optional): Field to stack by (record_typeortype)distinct(optional):record_idis the only value the service acts on — it returns the latest row perrecord_id. Any other value is neither honoured nor rejected; it falls through to the ordinary listing. Passing it also turns an unknown search field from a 400 into a 500.
Example Request
GET {{base_url}}/v1/items/set?limit=10&offset=0&search.type=RISK_LEVELS&search.active=true&sort=-created_at
Example Request with Distinct (Latest Status per Record)
To get the latest status for each record_id and then filter by name:
GET {{base_url}}/v1/items/set?limit=10&offset=0&search.type=ACCOUNT_CLOSURE_REQUEST&search.active=true&search.name=OPEN&sort=-created_at&distinct=record_id
This query will:
- First get the latest record for each
record_id(based oncreated_atDESC) - Then filter those results where
name = 'OPEN' - Apply sorting and pagination to the final results
This is useful for historical data where each record has multiple status changes over time, and you want to find records that are currently in a specific state.
Response (Paginated)
{
"data": [
{
"id": "uuid-string",
"record_id": "string",
"record_type": "string",
"type": "string",
"name": "string",
"created_at": "2025-02-24T13:19:41+01:00",
"created_by": "user-uuid",
"modified_at": "2025-02-24T13:19:41+01:00",
"modified_by": "user-uuid",
"active": true,
"metadata": {}
}
],
"total": 100,
"total_unfiltered": 100,
"has_more": true
}Response (Stacked)
{
"data": {
"record_type1": [ // Items grouped by record_type or type
{
"id": "uuid-string",
"record_id": "string",
"record_type": "string",
"type": "string",
"name": "string",
"created_at": "2025-02-24T13:19:41+01:00",
"created_by": "user-uuid",
"modified_at": "2025-02-24T13:19:41+01:00",
"modified_by": "user-uuid",
"active": true,
"metadata": {}
}
]
},
"total": 100,
"total_unfiltered": 100,
"has_more": false
}Error Codes
| Code | Description |
|---|---|
| items_m.item_not_found | Item not found |
| items_m.item_name_exists | Item with the same name already exists |
| items_m.item_invalid_type | Invalid item type provided |
| items_m.item_invalid_sort_order | Sort order is out of range |
| items_m.item_description_empty | Description must be provided for required languages |
| items_m.item_invalid_input | Invalid input data |
| items_m.item_failed_to_create | Failed to create item |
| items_m.item_failed_to_get | Failed to retrieve item |
| items_m.item_failed_to_update | Failed to update item |
| items_m.item_failed_to_delete | Failed to delete item |
| items_m.system_item_cannot_be_deleted | System items cannot be deleted |
| items_m.item_failed_to_list | Failed to list items |
| items_m.item_invalid_id | Invalid item ID |
| items_m.set_item_not_found | Set item not found |
| items_m.set_item_create_failed | Failed to create set item |
| items_m.set_item_list_failed | Failed to list set items |
These are the literal errs.MsgCode keys from , which is what the
code field of an error body carries verbatim. The shortened items.* / set_items.* forms
this table used before are not emitted by anything.
Five of them are declared and never constructed, so no request can produce them:
items_m.set_item_list_failed, items_m.set_item_not_found, items_m.item_invalid_type,
items_m.item_invalid_sort_order and items_m.item_description_empty. Set-item reads fail with
items_m.item_failed_to_list, the generic list key — which the item list route itself never
emits: a database fault on GET /v1/items is common.database_error, and the read-scope faults
that precede it carry common.rbac_failed_to_check_permission,
common.rbac_failed_to_cache_permission, rbac_m.failed_to_get_user_roles or
rbac_m.failed_to_fetch_permissions (GetPermittedRecordsCtx forces the status to 500 but leaves
the code alone).
Two shared codes appear on these routes as well: common.invalid_input for a malformed body or
a non-numeric limit/offset — apireply.BadRequest400 called with no AppError still fills
it in from its default, so a 400 is never code-less — and common.unauthorized for the 401.
Two more codes come from the shared query parser rather than from this module, and they do not
share a status. A bad value on a valid field is 400 query_m.invalid_search_value. An
unknown field is query_m.invalid_search_field, which validateFieldName builds with no
WithCode — and an empty code defaults to 500. Whether the caller sees that 500 depends on which
list route they asked, because the upgrade back to 400 lives in the count function, not in the
parser:
| Route | ?search.nosuchfield=1 | Why |
|---|---|---|
GET /v1/items | 500 | no totalFunc, so the count is the generic GetTotal → applySearch, which returns the code-less error unchanged |
GET /v1/items/set | 400 | GetTotalSetItems catches query_m.invalid_search_field and re-issues it WithCode(400) |
GET /v1/items/set?distinct=record_id | 500 | GetTotalDistinctSetItems has no such catch |
A malformed field name is a different case and is 400 everywhere: validateFieldName gives
query_m.invalid_field an explicit WithCode(400). So is an unsupported operator,
query_m.invalid_operator.
sort splits the same way. An unknown sort field is query_m.invalid_sort_field, built
WithCode(400), and GET /v1/items answers 400 with it — but only once the count found rows,
since the count pass skips sort parsing and an empty result never reaches the fetch. On
GET /v1/items/set the set-item count function rewraps it as items_m.item_failed_to_list
without a code, so the same mistake is a 500; with distinct=record_id the sort is not
validated at all.
There is no working way to filter or sort either list by modification time. Both field maps
declare updated_at — mapped to misc.items.updated_at and misc.set_items.updated_at — and
neither table has that column; the migration and the production baseline both name it
modified_at, and neither map has a modified_at entry. So search.updated_at reaches Postgres
and errors (500 on both routes), while search.modified_at is an unknown field and takes the
statuses in the table above. created_at is the only usable time field.
Refusals that never reach the handler
Every route in this module is wrapped by auth.WrapWithMiddlewares, and the root router adds a
further stack in front of it (RequestID, optional tracing and metrics, the request logger,
Recoverer, CORSMiddleware, RateLimitMiddleware when the switcher is on, LifecycleMiddleware,
logAPICall, RemoveTrailingSlash). Of those, exactly two answer a request on their own — the
rate limiter with 429 or 500, and the lifecycle middleware with the shutdown 503. Everything else
in the table below comes from the route-level auth.WrapWithMiddlewares chain. A client sees all
of it on all eight routes, whatever the operation does, and none of it carries an items_m.*
code.
| Status | Source | code in the body |
|---|---|---|
| 401 | auth.Middleware — missing, malformed, expired or blacklisted bearer token | common.unauthorized |
| 403 | rbac.CanCallAPIv0 denied the endpoint grant | common.rbac_no_rec_access |
| 403 | licence check | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed |
| 429 | auth.RateLimitMiddleware | see below |
| 500 | the rate limiter's own failures | common.server_error — the specific code is discarded |
| 503 | health.LifecycleMiddleware — graceful shutdown | none — a different body shape |
| 503 | auth.ensureCacheAvailable — auth cache unreachable | auth_m.internal_server_error |
items does not implement loader.LicensedModule, so the loader does not gate it and
license_m.module_not_licensed really is reachable here: CanCallAPIv0 derives the module from
the request path and checks it per request, in both the administrator-bypass and the normal RBAC
branch. (On the six modules the loader does gate, that code cannot appear — the routes are simply
absent.) Two further codes the middleware matches cannot reach a client at all:
license_m.license_service_unavailable is never written into the licence-error context, and
license_m.license_key_missing needs licenseService == nil, a state a running process cannot
be in because rbac.Init routes an InitLicenseService failure through logger.Fatalf.
429. auth.RateLimitMiddleware is mounted on the root router before authentication, and
only when app-config rate_limits.rate_limits_switcher is true — with the flag off no route
here can answer 429. Because it runs first, a request with no valid token can be rate-limited
without ever reaching the 401. Five values can land in code, and only three are dotted keys:
| Caller | code |
|---|---|
| No usable bearer token (per-IP global counter) | Global rate limit exceeded — the literal English string |
| Authenticated, per-permission limit tripped | rate_limits_m.exceeded, or the literal rate limit exceeded |
| Authenticated, global per-IP ceiling hit | rate_limits_m.global_exceeded |
| The limiter's own counter failed | rate_limits_m.failed_to_increment_ip_limit |
class is temporary and retryable is true on all of them, and no Retry-After header is
sent.
503 has two body shapes. During a graceful shutdown health.LifecycleMiddleware writes a
bare map, not the envelope — no status, code, class or retryable, and the message is a
fixed English string with no i18n key:
{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-28T15:04:05Z"
}The other 503 — an unreachable auth cache — is the envelope, with
code: "auth_m.internal_server_error". Note the auth_m prefix: ensureCacheAvailable calls
errs.New(MsgInternalServerError) unqualified from inside package auth, so it is not the
common.server_error constant of the same Go name. Branch on the shape, not on the status.
And that second 503 is only observable with the rate limiter off.
auth.RateLimitMiddleware is registered before auth.Middleware and touches the same
Redis/valkey, so with the switcher on a dead cache is answered by the limiter first — 500 for an
authenticated caller, 429 for an anonymous one. That 500 is generic:
handleRateLimitError's default branch calls apireply.InternalServerError500(w, r) without
the AppError, so auth_m.failed_to_cache_user_limits, auth_m.failed_to_fetch_user_roles and
rate_limits_m.failed_to_increment_ip_limit are discarded and the body reads
common.server_error. Do not use the 503 as your cache-down signal in a deployment that rate
limits.
created_by and modified_by are stripped for most callers
Unless app-config auth.audit_fields_internal is explicitly false, auth.WrapWithMiddlewares
also runs RemoveAuditFieldsHandler. It triggers on the Content-Type: application/json that
apireply.WithJSON always sets, re-marshals the whole body and deletes created_by and
modified_by recursively, at every nesting depth, for any caller without the internal role.
The keys are then absent, not null — and because the body round-trips through a Go map, key
order is not preserved either. The examples in this manual are inconsistent about it: the
set-item ones carry both keys, the plain-item ones do not. Read either as one role's view, not
as the field list.
RBAC Permissions
Permissions are record permissions on the items record type (ItemRecName), expressed as
CRUDA masks — not named strings like items.create:
| Operation | Mask on record type items |
|---|---|
| Create | C |
| Read | R |
| Update | U |
| Delete | D |
POST /v1/items/set is the exception: it checks R on the record type named in the request's
record_type, i.e. the record the item is being attached to, not on items.
A denial is a
403carryingcommon.rbac_no_rec_access.rbac.RecPermissionanswers a clean denial with(false, nil)— no error — so each call site in builds the refusal itself. It used to hand that nil back instead, which left create answering201with a body ofnull, read and update answering200withnull, and delete answering204without deleting; that is fixed. The same403also comes from the endpoint-levelCanCallAPIv0gate in the auth middleware, with its own code, so branch on the code rather than the status.The two list routes are a different mechanism and are NOT affected:
GET /v1/itemsandGET /v1/items/setenforce read scope inside the query.GetAllTotalasks for the read-all (A) mask and, when the caller lacks it, merges the record ids they may read into the query as a scope predicate — so a caller sees only their permitted rows, and one with none gets an empty list withtotal: 0rather than a403.
GET /v1/items/types/distinctis the only route with no record-permission check at all. Its counts also cover active items only (WHERE active = true).
Response Examples
Successful Item Creation
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"type": "product",
"name": "unique-product-name",
"title": {
"en": "Product Title",
"de": "Produkttitel"
},
"description": {
"en": "Product description",
"de": "Produktbeschreibung"
},
"sort_order": 1,
"active": true,
"system": false,
"created_at": "2025-02-23T16:14:05Z",
"modified_at": "2025-02-23T16:14:05Z"
}Error Response
{
"status": 409,
"code": "items_m.item_name_exists",
"message": "Item with this name already exists",
"class": "business"
}
Returns the current status of a v3 authoring OCR job. When status=200 the Swiss-shaped result is populated. A job of a different kind (v1/v2) is reported as not found.
Creates an item. The name must be unique within the type. A caller without the create permission on items is refused with 403 `common.rbac_no_rec_access`. Accept-Language is not read on this route: only GetItem and ListItems pull constants.AcceptLanguage from the request, and this 201 body carries no localised fields.