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.
How it works
Section titled “How it works”Custom Fields has two layers:
- Field definitions – created via the
/api/v1/custom-fieldsendpoints. 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 afieldKeyis unique per owner – see Uniqueness and shadowing. - Field values – stored in a dedicated
device_custom_field_valuestable, one row per device/definition pair, and validated against the definition’s declared type on every write. Thecustom_fieldsJSONB 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 enforcedfield_keypattern, preserved as-is) for backward compatibility – every existing reader ofdevices.custom_fieldskeeps working unchanged. Values are set by patching the device viaPATCH /api/v1/devices/:idwith acustomFieldskey-value map. Values are merged with any existing custom fields on the device rather than replacing them.
Field types
Section titled “Field types”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" |
Validation options
Section titled “Validation options”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.
Uniqueness and shadowing
Section titled “Uniqueness and shadowing”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:
fieldKeyis 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 returns409 { code: "field-key-duplicate" }.- An organisation-owned key cannot shadow an all-organisations key, or vice versa. If
asset_tagalready exists as a partner-wide field, no organisation under that partner can also define an organisation-ownedasset_tag(and the reverse). Creating a shadowing definition returns409 { 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.
Device type targeting
Section titled “Device type targeting”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"]}Scoping and multi-tenancy
Section titled “Scoping and multi-tenancy”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 |
Access rules
Section titled “Access rules”- 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.
Creating a field definition
Section titled “Creating a field definition”-
Choose a human-readable
name(1–100 characters) and a machine-readablefieldKey. The key must start with a lowercase letter and contain only lowercase letters, digits, and underscores (regex:^[a-z][a-z0-9_]*$). -
Pick a
typefrom the supported list:text,number,boolean,dropdown, ordate. -
Optionally set
options,required,defaultValue, anddeviceTypes. -
Send the request:
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 }'{ "data": { "id": "a1b2c3d4-...", "orgId": "org-uuid-...", "partnerId": null, "name": "Department", "fieldKey": "department", "type": "dropdown", "options": { "choices": ["Finance", "Engineering", "Sales", "Support"] }, "required": true, "scriptWrite": false, "defaultValue": null, "deviceTypes": null, "createdAt": "2026-02-18T12:00:00.000Z", "updatedAt": "2026-02-18T12:00:00.000Z" }}Setting values on devices
Section titled “Setting values on devices”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.
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" } }'Accepted value types
Section titled “Accepted value types”Each value must first match the Zod wire schema (string, number, boolean, or null – null 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).
Writing custom fields from a script
Section titled “Writing custom fields from a script”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.
Enabling it on a field
Section titled “Enabling it on a field”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.
The marker
Section titled “The marker”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.
$fields = @{ ram_slot_type = 'DDR5-5600'; free_dimm_slots = 2 }Write-Output "::breeze:custom-fields:: $($fields | ConvertTo-Json -Compress)"echo "::breeze:custom-fields:: {\"ram_slot_type\":\"DDR5-5600\",\"free_dimm_slots\":2}"import jsonprint("::breeze:custom-fields:: " + json.dumps({"ram_slot_type": "DDR5-5600"}))Type coercion and validation
Section titled “Type coercion and validation”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.
Reading the result
Section titled “Reading the result”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.
Querying and filtering
Section titled “Querying and filtering”Listing field definitions
Section titled “Listing field definitions”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) |
curl "/api/v1/custom-fields?type=dropdown&search=department" \ -H "Authorization: Bearer <token>"Filtering devices by custom field values
Section titled “Filtering devices by custom field values”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.
Updating a field definition
Section titled “Updating a field definition”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.
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"] } }'Deleting a field definition
Section titled “Deleting a field definition”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.
curl -X DELETE /api/v1/custom-fields/<field-id> \ -H "Authorization: Bearer <token>"Audit logging
Section titled “Audit logging”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.
Values in exports and the partner API
Section titled “Values in exports and the partner API”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 warranty mapping target
Section titled “The warranty mapping target”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.
API reference
Section titled “API reference”All endpoints require authentication and an appropriate scope (organization, partner, or system).
Field definitions
Section titled “Field definitions”| 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 |
Device values
Section titled “Device values”| 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 |
Create field definition request body
Section titled “Create field definition request body”{ 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}Update field definition request body
Section titled “Update field definition request body”{ 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;}Database schema
Section titled “Database schema”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.
Troubleshooting
Section titled “Troubleshooting”Field key rejected
Section titled “Field key rejected”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.