Skip to main content

User-Defined Fields

Endpoints for managing user-defined fields (custom profile attributes attached to User accounts), setting field values on a specific User, and discovering the registered custom-field drivers (the keyword contexts the script and painless engines expose).

A user-defined field is addressed by its name, which is the per-tenant natural key (there is a unique (tenant, name) index). The model's primary key is a server-generated UUID id, but every {name} route resolves the row by the name column, and the create route's Location header points at /api/user-fields/\{name\}.

There is no license-feature gate on any route in this area. The three write routes (create, update, delete) and the set-value route each require Superuser (via permission.userField.create, permission.userField.edit, and permission.userField.delete, all of which reduce to a superuser check). The read routes (list and get a field) and the two driver-discovery routes are open within the tenant with no permission pre-block. All lookups are tenant-scoped by row-level security but not owner-scoped, so any Superuser in the tenant can read, edit, or delete any field. User fields have no owner.

GET /api/user-fields

List the tenant's user-defined fields.

Authentication: Open within the tenant (no permission pre-block)

Response:

This is not a HAL { total, limit, offset, results } collection envelope and not a model query. It is a hand-written raw-SQL SELECT that returns a bare JSON array of rows, each shaped { name, label, description, defaultValue, type, createdAt, updatedAt }. The UUID id is intentionally omitted. Row-level security scopes the rows to the caller's tenant even though the SQL has no explicit tenant clause. The SQL has no ORDER BY, so order is not guaranteed; this example is sorted by name. On a query error the route answers 422. HAL api rel inf:user-field-list.

FieldTypeDescription
namestringPer-tenant natural key for the field
labelstringDisplay label
descriptionstring | nullField description
defaultValueany | nullDefault value (stored as JSONB)
typestringData type label (e.g. Text, Number, Date)
createdAt / updatedAtdateTimestamps
List user fieldsGET /api/user-fields
GET /api/user-fields is open within the tenant (no permission pre-block). It is NOT a HAL {total,limit,offset,results} envelope and NOT a model query — it is a hand-written raw-SQL SELECT returning a bare JSON ARRAY of rows { name, label, description, defaultValue, type, createdAt, updatedAt } (the UUID id is intentionally omitted). RLS scopes the rows to the caller’s tenant. The SQL has no ORDER BY, so order is not guaranteed; this example is sorted by name. HAL api rel inf:user-field-list.
Response · 200
[
{
"name": "costCenter",
"label": "Cost Center",
"description": "Accounting cost-center code for this user",
"defaultValue": null,
"type": "Text",
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
},
{
"name": "department",
"label": "Department",
"description": null,
"defaultValue": null,
"type": "Text",
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
},
{
"name": "region",
"label": "Sales Region",
"description": "Sales region this user belongs to",
"defaultValue": "Unassigned",
"type": "Text",
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
]
Captured from the API examples test suite; ids and timestamps are normalized.

POST /api/user-fields

Create a user-defined field.

Authentication: Superuser (permission.userField.create)

Request Body:

The payload is stripUnknown, so unrecognized fields are dropped.

FieldTypeRequiredDescription
namestringYesPer-tenant natural key (unique (tenant, name))
labelstringYesDisplay label
typestringYesData type label. A free string (e.g. Text, Number, Date); not validated against an enum here
descriptionstringNoField description
defaultValueanyNoDefault value, stored as JSONB (allows null or "")

Example Request:

{
"name": "employeeId",
"label": "Employee ID",
"type": "Text",
"description": "Internal employee identifier",
"defaultValue": null
}

Response:

Responds 201 Created with the created field and a Location header pointing at /api/user-fields/\{name\}. The UUID id is server-generated. HAL api rel inf:user-fields.

Create a user fieldPOST /api/user-fields
POST /api/user-fields requires permission.userField.create (superuser). Payload is stripUnknown: { name (required), label (required), type (required), description?, defaultValue? }. `type` is a free string (e.g. Text, Number, Date — not enum-validated here) and `defaultValue` is stored as JSONB. The handler does UserField.create(payload) and responds 201 with a Location header (/api/user-fields/{name}). `name` is the per-tenant natural key (unique (tenant, name)); the UUID id is server-generated. HAL api rel inf:user-fields.
Request body
{
"name": "employeeId",
"label": "Employee ID",
"type": "Text",
"description": "Internal employee identifier",
"defaultValue": null
}
Response · 201
{
"_links": {
"self": {
"href": "https://informer.example.com/api/user-fields/employeeId"
}
},
"id": "00000000-0000-4000-8000-000000000001",
"tenant": "acme",
"name": "employeeId",
"label": "Employee ID",
"type": "Text",
"description": "Internal employee identifier",
"defaultValue": null,
"updatedAt": "2026-01-15T16:20:00.000Z",
"createdAt": "2026-01-15T16:20:00.000Z"
}
Captured from the API examples test suite; ids and timestamps are normalized.

GET /api/user-fields/{name}

Retrieve a single user-defined field by name.

Authentication: Open within the tenant (no permission pre-block)

Path Parameters:

ParameterTypeDescription
namestringThe field's name (the per-tenant natural key), not the UUID id

Response:

The single field. Answers 404 if no field with that name exists in the tenant. HAL rel inf:user-field.

Get a user fieldGET /api/user-fields/region
GET /api/user-fields/{name} resolves the field by its `name` (the per-tenant natural key), not by UUID id (db.lookup, HAL rel inf:user-field). Open within the tenant. 404 if no field with that name exists.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/user-fields/region"
}
},
"id": "00000000-0000-4000-8000-000000000001",
"name": "region",
"tenant": "acme",
"label": "Sales Region",
"description": "Sales region this user belongs to",
"defaultValue": "Unassigned",
"type": "Text",
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
Captured from the API examples test suite; ids and timestamps are normalized.

PUT /api/user-fields/{name}

Update a user-defined field.

Authentication: Superuser (permission.userField.edit)

Path Parameters:

ParameterTypeDescription
namestringThe field's current name, used to locate the row (WHERE name = {name})

Request Body (partial):

The payload is stripUnknown. All fields are optional on update.

FieldTypeDescription
namestringNew name. Send a new value here while the URL keeps the OLD name to rename the field
labelstringDisplay label
descriptionstringField description
defaultValueanyDefault value (JSONB)
typestringData type label
Renaming a field

The {name} in the URL always locates the existing row. To rename, keep the OLD name in the URL and send the new name in the body.

Example Request:

{
"label": "Sales Region (Updated)",
"description": "Primary sales region this user is assigned to",
"defaultValue": "North America"
}

Response:

Responds 200 with the updated field. Answers 404 if no field with that name exists. HAL rel inf:user-field.

Update a user fieldPUT /api/user-fields/region
PUT /api/user-fields/{name} requires permission.userField.edit (superuser). stripUnknown payload { name?, label?, description?, defaultValue?, type? } — all optional on update. The {name} in the URL locates the row (WHERE name = {name}); to rename, send a new `name` in the body while the URL keeps the OLD name. Returns the updated field (db.update). 404 if missing.
Request body
{
"label": "Sales Region (Updated)",
"description": "Primary sales region this user is assigned to",
"defaultValue": "North America"
}
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/user-fields/region"
}
},
"id": "00000000-0000-4000-8000-000000000001",
"name": "region",
"tenant": "acme",
"label": "Sales Region (Updated)",
"description": "Primary sales region this user is assigned to",
"defaultValue": "North America",
"type": "Text",
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
Captured from the API examples test suite; ids and timestamps are normalized.

DELETE /api/user-fields/{name}

Delete a user-defined field by name.

Authentication: Superuser (permission.userField.delete)

Path Parameters:

ParameterTypeDescription
namestringThe field's name

Response:

Responds 200 with an empty body (Informer's db.remove convention, where the handler returns h.continue), not 204.

Delete a user fieldDELETE /api/user-fields/department
DELETE /api/user-fields/{name} requires permission.userField.delete (superuser) and removes the field by name. Returns 200 with an EMPTY body (Informer db.remove convention — the handler returns h.continue), NOT 204.
Response · 200 · no body
Captured from the API examples test suite; ids and timestamps are normalized.

PUT /api/user-field-value/{username}

Set one or more user-defined-field values on a specific User.

Authentication: Superuser (permission.userField.edit)

This route does not touch the user-field table. It loads the User by username (the User primary key), shallow-merges value into the user's userFields JSONB column, and saves. Omitted keys are preserved.

Path Parameters:

ParameterTypeDescription
usernamestringThe User's username

Request Body:

FieldTypeRequiredDescription
valueobjectYesA map of field name to value. Merged into the User's userFields column
Unknown username is not a clean 404

The {username} is not resolved through a lookup pre-block. An unknown username makes the User lookup return null and the handler throws (a 500-class error) rather than answering a clean 404. Send a username that exists.

Example Request:

{
"value": {
"region": "North America",
"costCenter": "CC-1001"
}
}

Response:

Responds 200 with the saved User (the full user representation). HAL rel inf:user-field-value.

Set a user field value on a userPUT /api/user-field-value/admin
PUT /api/user-field-value/{username} does NOT touch the UserField table. It loads the User by username (User PK is username), shallow-MERGES req.payload.value into the user’s `userFields` JSONB column, and saves. Payload is { value } where `value` is a required object (fieldName -> value); omitted keys are preserved. Requires permission.userField.edit (superuser). Returns the saved User (HAL rel inf:user-field-value). NOTE: {username} is not resolved through a lookup pre-block — an unknown username does not produce a clean 404. Captured here as admin setting values on itself.
Request body
{
"value": {
"region": "North America",
"costCenter": "CC-1001"
}
}
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/user-field-value/admin"
},
"inf:memberships": {
"href": "https://informer.example.com/api/user-field-value/admin/memberships"
},
"inf:avatar-upload": {
"href": "https://informer.example.com/api/user-field-value/admin/_upload/{uploadId}/avatar-upload",
"templated": true
},
"inf:password": {
"href": "https://informer.example.com/api/user-field-value/admin/password"
},
"inf:feed": {
"href": "https://informer.example.com/api/user-field-value/admin/feed"
},
"inf:viewed-feed": {
"href": "https://informer.example.com/api/user-field-value/admin/_markFeedViewed"
},
"inf:superuser": {
"href": "https://informer.example.com/api/user-field-value/admin/superuser"
}
},
"permissions": {
"changeDomain": false,
"changeProfile": true,
"changePassword": true,
"delete": false,
"edit": true,
"reassign": true,
"superuser": true,
"enable": true,
"changeTheme": true,
"lock": true,
"setGlobalPermissions": true,
"manageLogin": true
},
"domain": "local",
"username": "admin",
"displayName": "acme Administrator",
"familyName": "Administrator",
"givenName": "acme",
"middleName": null,
"email": null,
"data": null,
"superuser": true,
"timezone": "America/New_York",
"settings": {
"log": {
"log": false,
"info": false,
"warn": false,
"debug": false,
"error": true,
"remote": false
}
},
"enabled": true,
"source": null,
"sourceId": null,
"lastViewedFeed": null,
"tenant": "acme",
"globalPermissions": {
"ai": false,
"jobCreation": true,
"viewAllTeams": true,
"viewAllUsers": true,
"painlessScriptCreation": true
},
"userFields": {
"region": "North America",
"costCenter": "CC-1001"
},
"locked": false,
"lockedAt": null,
"loginAttempts": 0,
"passwordSetAt": "2026-01-15T16:20:00.000Z",
"passwordExpiresAt": "2026-01-15T16:20:00.000Z",
"forgotPasswordRequestTime": null,
"mfa": null,
"mfaConfig": {},
"aiMessage": null,
"aiConsent": false,
"hasSharedDatasources": false,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"initials": "EA",
"colorIndex": 4,
"avatarColor": {
"background": "#81C784",
"text": "#1B5E20"
},
"managesPassword": true
}
Captured from the API examples test suite; ids and timestamps are normalized.

GET /api/custom-field-drivers

List the registered custom-field drivers.

Authentication: Open within the tenant (no permission pre-block)

Response:

Lists the drivers registered in the customFields driver manager. Only the user driver ships (id user, scope request). Each driver is spread and gets a variables array; the driver's function-valued properties are dropped by JSON serialization, so each item is { id, scope, label, variables }. A driver whose variable resolution throws is returned without variables rather than failing the whole route. The variables come from a query with no ORDER BY, so this example sorts them by name. HAL rel inf:custom-field-drivers.

The user driver's variables are one entry per user-defined field (with dataType equal to the field's type, context of user, and the field's UUID id) plus six built-in user-attribute keywords (username, displayName, email, timezone, superuser, enabled), each with dataType of Text.

FieldTypeDescription
idstringDriver id (e.g. user)
scopestringDriver scope (e.g. request)
labelstringDriver label (an i18n message key)
variablesarrayThe keyword variables this driver exposes
List custom field driversGET /api/custom-field-drivers
GET /api/custom-field-drivers lists the drivers registered in the `customFields` driver manager (open within the tenant). Only the `user` driver ships (id "user", scope "request"). Each driver is spread and gets `variables` (await driver.getVariables(req)); the driver’s function-valued props are dropped by JSON, so each item is { id, scope, label, variables }. The `user` driver’s variables = one entry per UserField (dataType = the field’s type, context "user", plus its UUID id) PLUS six built-in user-attribute keywords (username, displayName, email, timezone, superuser, enabled; dataType Text). A driver whose getVariables throws is returned WITHOUT variables (never fails the route). Variables come from findAll() with no ORDER BY, so this example sorts them by name. HAL rel inf:custom-field-drivers.
Response · 200
[
{
"id": "user",
"scope": "request",
"label": "common:user_field_plural",
"variables": [
{
"label": "Cost Center",
"name": "costCenter",
"description": "Accounting cost-center code for this user",
"dataType": "Text",
"context": "user",
"defaultValue": null,
"id": "00000000-0000-4000-8000-000000000001"
},
{
"name": "displayName",
"label": "Display Name",
"dataType": "Text",
"context": "user"
},
{
"name": "email",
"label": "Email",
"dataType": "Text",
"context": "user"
},
"… 6 more items (9 total) — omitted from docs"
]
}
]
Captured from the API examples test suite; ids and timestamps are normalized.

GET /api/custom-field-drivers/{id}

Retrieve a single custom-field driver by id.

Authentication: Open within the tenant (no permission pre-block)

Path Parameters:

ParameterTypeDescription
idstringDriver id (here user)

Response:

The single driver with its variables, the same shape as one element of the list. Variables are sorted by name for a stable example. HAL rel inf:custom-field-driver.

Get a custom field driverGET /api/custom-field-drivers/user
GET /api/custom-field-drivers/{id} returns one driver by id (here id = "user") with its `variables`, same shape as one element of the list. HAL rel inf:custom-field-driver. Open within the tenant. Variables sorted by name for a stable example.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/custom-field-drivers/user"
}
},
"id": "user",
"scope": "request",
"label": "common:user_field_plural",
"variables": [
{
"label": "Cost Center",
"name": "costCenter",
"description": "Accounting cost-center code for this user",
"dataType": "Text",
"context": "user",
"defaultValue": null,
"id": "00000000-0000-4000-8000-000000000001"
},
{
"name": "displayName",
"label": "Display Name",
"dataType": "Text",
"context": "user"
},
{
"name": "email",
"label": "Email",
"dataType": "Text",
"context": "user"
},
"… 6 more items (9 total) — omitted from docs"
]
}
Captured from the API examples test suite; ids and timestamps are normalized.