Custom Fields
Custom fields let each workspace extend Leads, Contacts, and Deals with its own data — a policy number, a renewal date, a carrier dropdown — without a code change. Definitions are stored per tenant and their values live in a JSONB `custom_fields` column on each record, so what you define in Settings shows up automatically in create forms, record details, and list-view columns.
#What custom fields are
A custom field is a per-tenant definition that adds a data point to one CRM module. Definitions are rows in the tenant_custom_fields table; the actual values you capture are written into a JSONB custom_fields object on the individual Lead, Contact, or Deal record. That two-part design means adding or removing a field never requires a schema migration — only the definition changes.
Custom fields are scoped to three modules, referred to internally as object_type values:
| Module | object_type | Where values are stored |
|---|---|---|
| Leads | leads | leads.custom_fields (JSONB) |
| Contacts | contacts | contacts.custom_fields (JSONB) |
| Deals | deals | deals.custom_fields (JSONB) |
tenant_id, and row-level security limits reads to your own workspace. Only members with the agency_admin role can create, edit, delete, or reorder definitions — everyone else can read and fill them in.#Supported field types
When you create a field you pick one of nine types. The type controls the input rendered in forms and how the value is displayed in list views and record details.
| Type | Label in UI | Input rendered | Notes |
|---|---|---|---|
| text | Text | Single-line text input | Default fallback type |
| number | Number | Numeric input | Stored as a number, not a string |
| Email input | Browser email validation | ||
| phone | Phone | Tel input | No formatting enforced |
| date | Date | Date picker | Displayed via toLocaleDateString() |
| select | Dropdown | Select menu | Requires an options list |
| textarea | Multi-line Text | Textarea | Spans full width in forms |
| checkbox | Checkbox | Checkbox | Shows as Yes/No in list views |
| url | URL | URL input | Rendered as a clickable link |
Aetna, Cigna, Humana). Options are stored on the definition as options.choices and become the menu items in every form.#Create a custom field
Custom fields are managed at /dashboard/settings/custom-fields. The page has one tab per module — Leads, Contacts, Deals — and each tab lists that module's fields in display order.
- 1Open the managerGo to Settings, then Custom Fields. Pick the module tab you want to extend.
- 2Add a fieldClick + Add Field to open the inline creation form.
- 3Name the fieldEnter a Field Name (internal key), e.g.
policy_number. This is normalized to a lowercase snake_case slug automatically — spaces and punctuation become underscores. - 4Set a display nameOptionally add a Display Name like "Policy Number". If left blank, the internal key is used as the label.
- 5Pick a typeChoose the Field Type. If you pick Dropdown, fill in the comma-separated Options box that appears.
- 6Mark required (optional)Tick Required field if the value must be filled in, then click Create Field. It saves immediately and appears in the list.
UNIQUE(tenant_id, object_type, field_name). Creating a second policy_number on Leads returns a duplicate error (Postgres code 23505) and the UI shows "A field with this name already exists for this module". The same key can be reused on a different module.#Where fields appear
Once a definition exists, it surfaces across the module automatically — you do not wire anything up per screen.
- Create & edit forms — a "Custom Fields" section renders on the New Lead and New Contact pages via the shared
CustomFieldsFormSectioncomponent, with the correct input for each type. - Record details — the same section renders on individual record pages so values can be viewed and updated.
- List views — the
useCustomFieldshook produces dynamic columns (idcf_<field_name>) you can enable in a module's table. Checkbox fields show Yes/No, dates are localized, and URLs become links. - Required fields — labels are marked with an asterisk when
requiredis true.
Editing a definition (display name, type, description, options, required, or order) is done inline from the same Settings page. Fields can be reordered with the up/down arrows, which persists order_position, and deleting a field is a two-click confirm.
custom_fields JSONB remain on those records — they simply become invisible unless you recreate a definition with the same key.#Data model reference
Definitions live in tenant_custom_fields. Each row has the following columns:
iduuid- Primary key of the definition.
tenant_iduuid- Owning workspace; enforced by row-level security.
object_typetext- Target module: leads, contacts, or deals.
field_nametext- Internal snake_case key used as the JSONB key on records.
field_typetext- One of the nine supported types (text, number, select, and so on).
display_nametext | null- Human-readable label; defaults to field_name.
descriptiontext | null- Optional helper text used as an input placeholder.
optionsjsonb | null- Type-specific config; for dropdowns holds { choices: string[] }.
requiredboolean- Whether the value must be provided. Defaults to false.
order_positionint- Sort order within the module. Defaults to 0.
created_attimestamptz- When the definition was created.
On a record, a saved value keys off field_name. For a lead with a policy_number text field, a carrier dropdown, and an auto_renew checkbox, the payload written on create looks like this:
{
"policy_number": "POL-48213",
"carrier": "Aetna",
"auto_renew": true
}#Registering fields during migration
When importing from another CRM, fields can be created on demand rather than by hand. The POST /api/migrate/custom-fields endpoint registers a single definition and is idempotent — calling it twice for the same key returns created: false instead of erroring, thanks to the unique constraint.
OLYRON_MIGRATE_SIGNING_SECRET environment variable — they are not the path the in-app Settings UI uses. Advisors and admins create fields through the Settings page described above.{
"orgId": "b1e2c3d4-....", // tenant UUID
"entity": "contact", // maps to object_type
"key": "legacy_id", // snake_case; becomes field_name
"label": "Legacy ID", // becomes display_name
"type": "string", // string|text|number|boolean|date|datetime|email|phone|url|enum
"enumValues": ["A", "B"] // optional; stored as options.enum for enum type
}{
"fieldKey": "legacy_id",
"created": true // false if the field already existed
}Note the migrate contract's type enum is broader than the in-app type list (it includes string, boolean, datetime, and enum), and enum choices are stored under options.enum rather than options.choices. If you register fields via migration and want them to render as dropdowns in the in-app forms, map them to the select type with options.choices.
#Troubleshooting & limits
| Symptom | Cause | Fix |
|---|---|---|
| "A field with this name already exists" | Duplicate field_name on the same module (unique constraint) | Pick a different internal key, or edit the existing field |
| Field doesn't appear in the create form | Definition is on a different module than the form | Confirm the correct module tab in Settings |
| Can't add or edit fields | Managing definitions requires the agency_admin role | Ask a workspace admin to make the change |
| Dropdown has no options | Options box was left empty when the field was created | Edit the field and add comma-separated options |
| Old values still show after deleting a field | Values persist in the record's custom_fields JSONB | Recreate a definition with the same key to view or clear them |