Skip to content

Custom Fields

Custom Fields let you attach structured metadata to devices that goes beyond the built-in attributes (hostname, OS type, tags, etc.). You define field definitions at the organisation or partner level, then set values on individual devices. This is useful for tracking asset numbers, purchase dates, warranty expiry, department assignments, compliance status, and any other data specific to your environment.

Migrating custom fields in bulk from another RMM? See Migrating to Breeze for the Import from another RMM wizard, which drives the same definitions and values endpoints this page documents.


Custom Fields has two layers:

  1. Field definitions – created via the /api/v1/custom-fields endpoints. A definition declares the field’s name, key, data type, validation options, and which device types it applies to. Definitions are scoped to either an organisation or a partner, and a fieldKey is unique per owner – see Uniqueness and shadowing.
  2. Field values – stored in a dedicated device_custom_field_values table, one row per device/definition pair, and validated against the definition’s declared type on every write. The custom_fields JSONB column on the device record is maintained as a trigger-rebuilt projection of that table (plus any pre-existing key that doesn’t match the enforced field_key pattern, preserved as-is) for backward compatibility – every existing reader of devices.custom_fields keeps working unchanged. Values are set by patching the device via PATCH /api/v1/devices/:id with a customFields key-value map. Values are merged with any existing custom fields on the device rather than replacing them.

Every field definition has a type that controls what values are accepted:

Type Description Example value
text Free-form string up to 10,000 characters "Rack A, Shelf 3"
number Numeric value (integer or decimal) 42
boolean True or false true
dropdown One of a predefined list of choices "Finance"
date Date string "2026-06-15"

When creating a field definition you can supply an options object to constrain accepted values:

Option Applies to Description
choices dropdown Array of allowed string values. Example: ["Finance", "Engineering", "Sales"]
min number Minimum numeric value
max number Maximum numeric value
pattern text Regular expression that text values must match
placeholder text, number, date Placeholder hint displayed in the UI

You can also set a defaultValue on the definition. If a device does not have an explicit value for the field, the default is used. Setting required: true indicates that the field should always be populated for applicable devices.


Two rules keep a fieldKey unambiguous, both enforced in the database and returned as 409 – both predate this page’s bulk importer and are what the importer relies on rather than re-implementing, since a mismatched key is otherwise silently ambiguous data corruption:

  • fieldKey is unique per owner. Two definitions cannot share a key on the same axis (two organisation-owned fields, or two partner-wide fields). Creating a duplicate returns 409 { code: "field-key-duplicate" }.
  • An organisation-owned key cannot shadow an all-organisations key, or vice versa. If asset_tag already exists as a partner-wide field, no organisation under that partner can also define an organisation-owned asset_tag (and the reverse). Creating a shadowing definition returns 409 { code: "field-key-shadowed" }.

Both rules exist because a value can only be attributed to one definition. Without them, a device could resolve two different definitions for the same key – disagreeing on type – with no way to tell which one a stored value belongs to.


A definition can optionally include a deviceTypes array to limit which operating systems it applies to. Accepted values are windows, macos, and linux.

When deviceTypes is omitted or set to null, the field applies to all device types.

{
"name": "BitLocker Recovery Key",
"fieldKey": "bitlocker_recovery_key",
"type": "text",
"deviceTypes": ["windows"]
}

Field definitions are scoped to one of:

Scope Who can create Visibility
Organisation Organisation-scoped users or partner users with access to the organisation Visible only within that organisation
Partner Partner-scoped users Visible to the partner and all organisations the partner can access
  • Organisation users see definitions scoped to their own organisation plus any definitions scoped to their parent partner.
  • Partner users see definitions scoped to their partner plus definitions scoped to any organisation they can access.
  • System-scoped users see all definitions.

Editing and deleting a definition requires ownership: organisation users can only modify definitions belonging to their organisation, and partner users can only modify definitions belonging to their partner.


  1. Choose a human-readable name (1–100 characters) and a machine-readable fieldKey. The key must start with a lowercase letter and contain only lowercase letters, digits, and underscores (regex: ^[a-z][a-z0-9_]*$).

  2. Pick a type from the supported list: text, number, boolean, dropdown, or date.

  3. Optionally set options, required, defaultValue, and deviceTypes.

  4. Send the request:

Terminal window
curl -X POST /api/v1/custom-fields \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Department",
"fieldKey": "department",
"type": "dropdown",
"options": {
"choices": ["Finance", "Engineering", "Sales", "Support"]
},
"required": true
}'

Values are set by sending a PATCH request to the device endpoint with a customFields object whose keys correspond to field keys. The request/response shape is unchanged from before this feature’s normalized storage landed: you still read and write the same customFields object on the device record – see Database schema for where it’s actually stored now.

Terminal window
curl -X PATCH /api/v1/devices/<device-id> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"customFields": {
"department": "Engineering",
"asset_number": "AST-2026-0042",
"purchase_date": "2025-11-01"
}
}'

Each value must first match the Zod wire schema (string, number, boolean, or nullnull removes the field), then be validated against the target definition’s own declared type and options. A value that fails either check is rejected – not silently stored – with 400 { error, code: "invalid-custom-field-value", fields: [{ fieldKey, reason }] }. reason is one of:

Reason Meaning
unknown_field No custom field definition visible to this device’s organisation owns that key.
invalid_type The value’s shape doesn’t match the field’s declared type.
out_of_range A number value falls outside the field’s min/max.
not_a_choice A dropdown value isn’t one of the field’s declared options.choices.
too_long A text value exceeds the fixed 10,000-character limit. (options.maxLength on a definition is a client-side UI hint only – it is not enforced by this check.)
invalid_date A date value doesn’t parse.
not_applicable_to_device The field’s deviceTypes excludes this device’s OS.

This applies to both PATCH /api/v1/devices/:id and PATCH /api/v1/devices/:id/custom-fields, and to script write-back (whose own rejection reasons are listed separately, below).


A script running on a device can set that device’s own custom fields directly from its output — no API key, no device UUID, and no extra network call. This is the equivalent of Ninja-Property-Set for Breeze.

Script write-back is opt-in per field definition, and off by default. Turn on Allow scripts to write this field (scriptWrite: true) on the field’s definition — see Creating a field definition — before any script can set it. A script targeting a field with scriptWrite: false (or a field that doesn’t exist) has that write rejected.

Print a line to stdout in this exact form:

::breeze:custom-fields:: {"key":"value"}

The text after the marker must be a single JSON object whose keys are field keys and whose values are the new field values. null clears a field, exactly like the device PATCH endpoint. A script can emit the marker more than once; later lines win for a repeated key.

Terminal window
$fields = @{ ram_slot_type = 'DDR5-5600'; free_dimm_slots = 2 }
Write-Output "::breeze:custom-fields:: $($fields | ConvertTo-Json -Compress)"

Each value is validated and coerced against the target field’s declared type, using the same rules as accepted value types above:

Field type Coercion
text Coerced to a string; rejected if it exceeds the field’s maxLength or fails its pattern
number Coerced to a number; rejected if it falls outside min/max
boolean Accepts true/false (JSON booleans)
dropdown The value must be one of the field’s declared options.choices
date Must parse as a valid date string

A value that fails coercion or validation is rejected for that key only — it does not fail the rest of the marker or the script run itself.

Every execution that emitted at least one marker gets a customFieldResult summary ({ applied: string[], rejected: [{ key, reason }] }), visible in the execution’s detail view in the web UI and via GET /scripts/executions/:executionId. rejected[].reason is one of:

Reason Meaning
unknown_field No custom field definition matches that key
not_script_writable The field exists but scriptWrite is off
not_applicable_to_device The field’s deviceTypes excludes this device
invalid_type / out_of_range / not_a_choice / too_long / invalid_date The value failed type coercion or validation — see the table above
marker_unparseable The marker line’s JSON could not be parsed — see the sanitizer caveat below
too_many_lines / too_many_keys / marker_too_large The marker exceeded the script’s per-run limits (20 marker lines, 50 keys, 8 KB of marker JSON)
forbidden_key The key was __proto__, constructor, or prototype

A silently-rejected write is exactly the failure this summary exists to prevent — check it whenever a value you expected doesn’t show up on the device.

To read a custom field value back into a script as an input, bind a script parameter with source From a device custom field — see Parameter sources.


GET /api/v1/custom-fields returns all field definitions visible to the authenticated user. Query parameters:

Parameter Type Description
type string Filter by field type (text, number, boolean, dropdown, date)
orgId UUID Filter to a specific organisation (must be accessible)
deviceType string Filter definitions applicable to a device type (windows, macos, linux)
search string Case-insensitive search across field name and fieldKey
page number Page number (default: 1)
limit number Results per page (default: 50, max: 100)
Terminal window
curl "/api/v1/custom-fields?type=dropdown&search=department" \
-H "Authorization: Bearer <token>"

Device list queries (GET /api/v1/devices) do not directly filter by custom field values in query parameters. To find devices with specific custom field values, retrieve the device list and filter client-side, or – if you have direct database access – query device_custom_field_values (see Database schema) rather than the device’s custom_fields JSONB projection; the normalized table carries an (org_id, field_key, value_text) index and is the source of truth.


Use PATCH /api/v1/custom-fields/:id to update a definition. You can change name, options, required, defaultValue, and deviceTypes. The fieldKey and type are immutable after creation.

Terminal window
curl -X PATCH /api/v1/custom-fields/<field-id> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"options": {
"choices": ["Finance", "Engineering", "Sales", "Support", "Operations"]
}
}'

DELETE /api/v1/custom-fields/:id removes the definition and cascades to every stored value under it – a value can never outlive its definition, since the value table’s foreign key is ON DELETE CASCADE. The device’s customFields projection updates in the same transaction. This is different from earlier behaviour, where a deleted definition left its values orphaned in the device’s JSONB; deletion is now destructive of the data, not just the schema, so treat it accordingly.

Terminal window
curl -X DELETE /api/v1/custom-fields/<field-id> \
-H "Authorization: Bearer <token>"

All mutating operations on custom field definitions are recorded in the audit log:

Action Trigger
custom_field.create A new field definition is created
custom_field.update A field definition is modified (logs which fields changed)
custom_field.delete A field definition is deleted

Device-level custom field value changes are logged under the device.update audit action when the device is patched.


Custom field values now appear in the tenant GDPR export (each value’s field_key and stored value land in the export archive), and the partner configuration API returns one record per datum. Previously, if an organisation-owned and an all-organisations definition happened to share a fieldKey – an anti-shadowing gap now closed, see Uniqueness and shadowing – the same value could be exported twice. Both fixes follow from the same underlying change: values are read from device_custom_field_values (one row is one datum) rather than from the device’s JSONB, which had no way to distinguish “one value” from “one value counted twice.”

The Import from another RMM wizard (see Migrating to Breeze) can map a source column onto warranty instead of a custom field. This is not a custom field at all – it writes device_warranty’s start date, end date, and manufacturer directly, and drives the warranty-expiry alerting the rest of the platform already has. A mapped warranty value is never applied over data a manufacturer API lookup already supplied unless the operator explicitly opts in to overriding it on that import; provider-sourced warranty data is treated as more authoritative than a bulk import by default.


All endpoints require authentication and an appropriate scope (organization, partner, or system).

Method Path Description
GET /api/v1/custom-fields List field definitions (paginated, filterable)
GET /api/v1/custom-fields/:id Get a single field definition
POST /api/v1/custom-fields Create a new field definition
PATCH /api/v1/custom-fields/:id Update a field definition
DELETE /api/v1/custom-fields/:id Delete a field definition
Method Path Description
PATCH /api/v1/devices/:id Set custom field values (include customFields in request body)
GET /api/v1/devices/:id Returns the device with customFields in the response
GET /api/v1/devices Lists devices; each includes its customFields object
{
name: string; // 1-100 characters
fieldKey: string; // 1-100 chars, regex: /^[a-z][a-z0-9_]*$/
type: "text" | "number" | "boolean" | "dropdown" | "date";
options?: {
choices?: string[]; // For dropdown type
min?: number; // For number type
max?: number; // For number type
pattern?: string; // Regex for text type
placeholder?: string;
};
required?: boolean; // Default: false
scriptWrite?: boolean; // Default: false - see "Writing custom fields from a script"
defaultValue?: unknown;
deviceTypes?: ("windows" | "macos" | "linux")[];
orgId?: string; // UUID - omit for org-scoped users
partnerId?: string; // UUID - omit for partner-scoped users
}
{
name?: string; // 1-100 characters
options?: {
choices?: string[];
min?: number;
max?: number;
pattern?: string;
placeholder?: string;
};
required?: boolean;
scriptWrite?: boolean;
defaultValue?: unknown;
deviceTypes?: ("windows" | "macos" | "linux")[] | null;
}

Field definitions are stored in the custom_field_definitions table:

Column Type Description
id UUID Primary key (auto-generated)
org_id UUID References organizations.id (nullable)
partner_id UUID References partners.id (nullable)
name VARCHAR(100) Human-readable field name
field_key VARCHAR(100) Machine-readable key
type ENUM One of text, number, boolean, dropdown, date
options JSONB Validation options (choices, min, max, pattern, placeholder)
required BOOLEAN Whether the field is required (default: false)
default_value JSONB Default value for the field
device_types TEXT[] Array of applicable OS types
created_at TIMESTAMP Creation timestamp
updated_at TIMESTAMP Last modification timestamp

Device values are stored one row per device/definition pair in device_custom_field_values:

Column Type Description
device_id UUID References devices.id (ON DELETE CASCADE)
org_id UUID Denormalised from the device, for tenant scoping
definition_id UUID References custom_field_definitions.id (ON DELETE CASCADE)
field_key VARCHAR(100) Denormalised from the definition, so a value survives being read without a join
value_text / value_number / value_bool / value_date typed columns At most one populated, matching the definition’s type. All four NULL means the value was explicitly cleared.
source VARCHAR(32) manual, api, script, import, or backfill – how the value was written

The custom_fields JSONB column on devices is retained as a trigger-maintained projection of this table – it is rebuilt on every write and is read-only in practice; writing it directly is reverted by the next value write. Existing integrations that read devices.custom_fields keep working unchanged.


The fieldKey must match the pattern ^[a-z][a-z0-9_]*$. It must start with a lowercase letter and contain only lowercase letters, digits, and underscores. Uppercase letters, hyphens, spaces, and leading digits are not allowed.

Invalid: Asset-Number, 3rd_floor, Department Name Valid: asset_number, third_floor, department_name

“Provide either orgId or partnerId, not both”

Section titled ““Provide either orgId or partnerId, not both””

When creating a field definition, supply at most one of orgId or partnerId. A field is scoped to a single tenant level. If you are authenticated as an organisation user, you do not need to supply either – the API infers your organisation from the auth context.

Custom field values not appearing on device

Section titled “Custom field values not appearing on device”

Verify that the PATCH /api/v1/devices/:id request includes a customFields key in the JSON body, not custom_fields. The API expects camelCase property names. Also confirm that each value is a string, number, boolean, or null – objects and arrays are not accepted as values.

Deleted a definition and the values are gone

Section titled “Deleted a definition and the values are gone”

This is expected: DELETE /api/v1/custom-fields/:id cascades to every stored value under that definition, and the device’s customFields projection reflects the deletion immediately. There is no undo – re-creating a definition with the same fieldKey does not restore the old values, because it is a new definition with a new id. If you need the data back, restore from a backup taken before the delete.

“Custom field not found” on GET/PATCH/DELETE

Section titled ““Custom field not found” on GET/PATCH/DELETE”

The field ID must be a valid UUID for a definition that exists and is accessible to your auth scope. Organisation users cannot see or modify partner-scoped definitions. Partner users cannot see or modify definitions belonging to organisations outside their access.