CorebanqCorebanq Developer Docs
Items

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, 0 and negatives other than -1 fall back to 10, and -1 is a counts-only mode that returns total/total_unfiltered with data empty.
  • offset (optional): Starting position for pagination. A negative value is treated as 0.
  • stack (optional): Field to stack by (record_type or type)
  • distinct (optional): record_id is the only value the service acts on — it returns the latest row per record_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:

  1. First get the latest record for each record_id (based on created_at DESC)
  2. Then filter those results where name = 'OPEN'
  3. 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

CodeDescription
items_m.item_not_foundItem not found
items_m.item_name_existsItem with the same name already exists
items_m.item_invalid_typeInvalid item type provided
items_m.item_invalid_sort_orderSort order is out of range
items_m.item_description_emptyDescription must be provided for required languages
items_m.item_invalid_inputInvalid input data
items_m.item_failed_to_createFailed to create item
items_m.item_failed_to_getFailed to retrieve item
items_m.item_failed_to_updateFailed to update item
items_m.item_failed_to_deleteFailed to delete item
items_m.system_item_cannot_be_deletedSystem items cannot be deleted
items_m.item_failed_to_listFailed to list items
items_m.item_invalid_idInvalid item ID
items_m.set_item_not_foundSet item not found
items_m.set_item_create_failedFailed to create set item
items_m.set_item_list_failedFailed 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=1Why
GET /v1/items500no totalFunc, so the count is the generic GetTotal → applySearch, which returns the code-less error unchanged
GET /v1/items/set400GetTotalSetItems catches query_m.invalid_search_field and re-issues it WithCode(400)
GET /v1/items/set?distinct=record_id500GetTotalDistinctSetItems 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.

StatusSourcecode in the body
401auth.Middleware — missing, malformed, expired or blacklisted bearer tokencommon.unauthorized
403rbac.CanCallAPIv0 denied the endpoint grantcommon.rbac_no_rec_access
403licence checklicense_m.license_invalid, license_m.license_expired, license_m.module_not_licensed
429auth.RateLimitMiddlewaresee below
500the rate limiter's own failurescommon.server_error — the specific code is discarded
503health.LifecycleMiddleware — graceful shutdownnone — a different body shape
503auth.ensureCacheAvailable — auth cache unreachableauth_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:

Callercode
No usable bearer token (per-IP global counter)Global rate limit exceeded — the literal English string
Authenticated, per-permission limit trippedrate_limits_m.exceeded, or the literal rate limit exceeded
Authenticated, global per-IP ceiling hitrate_limits_m.global_exceeded
The limiter's own counter failedrate_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:

OperationMask on record type items
CreateC
ReadR
UpdateU
DeleteD

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 403 carrying common.rbac_no_rec_access. rbac.RecPermission answers 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 answering 201 with a body of null, read and update answering 200 with null, and delete answering 204 without deleting; that is fixed. The same 403 also comes from the endpoint-level CanCallAPIv0 gate 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/items and GET /v1/items/set enforce read scope inside the query. GetAllTotal asks 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 with total: 0 rather than a 403.

GET /v1/items/types/distinct is 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"
}

On this page