Custom fields: the definition model and the dependency-scan lifecycle
A custom field turns business-specific contact metadata - a plan tier, an account manager, a renewal date - into a typed, validated, queryable attribute instead of a freeform note. Two different models sit behind that: the definition (your per-tenant schema that says what a field is and how its values are validated) and the value (the key’s value stored on each contact). This page is the definition model the Custom Fields guide and the Custom Fields API reference assume: what a definition is, which column a value lands in, who can edit the schema, and why the delete path runs a dependency scan before it removes anything.What a custom-field definition is
A definition is a row in the tenant-scopedcustom_field_definitions
table that describes one allowed key on a contact. Its shape:
key- the machine identifier (e.g.plan_tier). Frozen at creation.type-text,number,date,boolean,select,multiselect, oruser_ref. Frozen at creation, alongside thekey, because changing either would orphan values already stored on contacts.options- the allowed list, required forselect/multiselect.default_value- backfilled onto contacts at create time.required- gates every contact create / update until a value is supplied. See the required-field gate below.searchable- whether the field participates in its pickers.display_order- its position in the field list; the reorder endpoint in the route table below writes to it.- Validators -
validator_regex,min_len/max_len, andenum_values, applied whenever a value is written. A failing value returns422 VALIDATION_ERROR.
The dual JSONB storage model
Contact custom-field values occupy two JSONB columns on thecontacts
table, not one:
custom_attributes- the canonical store. Inline edits, thePUT /custom-fields/valueswrite, and create-time promotion write here.attributes- the legacy store. CSV import packs values here, and the create form / required-gate path reads from here.
custom_attributes first,
legacy attributes second - so a per-contact key collision resolves
deterministically to the canonical value, and a field populated only in
the legacy store still reports its usage. This is the
CONTACT_ATTRIBUTE_COLUMNS tuple in the controller, and it is why a
CSV-imported audience shows a non-zero usage count even though it never
wrote a single custom_attributes byte.
Because one of the columns is added by a later tenant migration, the scan
first checks which of the two columns exist on the tenant (a base-schema
column is always there; the canonical one arrives later). The probe THEN
builds its predicate from only the present columns, so a partial-migration
tenant gets a real count from whichever column it has - instead of a
statement that referenced both columns and failed at analysis time,
silently reporting zero usage. This is why “Usage shows 0 for a populated
field” on an imported audience is a real care-more case, not a cosmetic
one.
Roles - who can do what
All routes in this group sit behind tenant authentication. Schema-changing writes - create, update, delete, reorder, set value, force-delete - require owner, admin, or developer. Viewers can read the definitions, dependency scan, and explorer payload. The distinction matters because reorder and force-delete are schema mutations even when they touch no values.Validation rules degrade, not error
A value that fails the field’s validators returns422 VALIDATION_ERROR.
The exception is reorder: POST /custom-fields/reorder accepts a list of
definition IDs and ignores unknown IDs rather than erroring on them.
That degrade-not-error choice keeps an optimistic UI update safe if
another admin deletes a row mid-drag - the request succeeds and omitted
IDs keep their current display_order. Duplicate IDs in the same request
are the one hard rejection, because they would silently collide on
position.
The dependency scan
Deleting a definition without knowing who references it breaks every dependent surface silently - a segment filter returns zero matches, a campaign mediates on a broken personalization token, a flow condition never fires. The dependency scan enumerates the blast radius BEFORE any delete is allowed through. It reports counts and id/name lists across five surfaces:contacts_with_data- contacts holding the key in either attribute column, counted via the dual-column probe above.segments- segments referencing the key in theirfiltersast or the legacyrulescolumn.campaigns- campaigns referencing the key inmessage_template(mustache{{contact.custom.<key>}}, a TEXT scan),variables, orjourney_definition(structured filter JSON).drip_enrollments- active or paused drip enrollments tied to any campaign in the list above (a count, not an id list, because enrollments are not an operator-facing concept).flows- automation flows and IVR flows referencing the key in theirdefinitionortrigger_config(pluspublished_definitionfor IVR).
custom_attributes.<key>), the legacy singular prefix
(custom_attribute.<key>), or a bare filter-AST field leaf, the scan
matches all three markers. The five-surfaces fan-out runs concurrently;
only the drip-enrollment count resolves after campaigns, because it
filters on the campaign id list. Every surface is LIKE-matched over a
COALESCE(...::text) cast so a missing column degrades to a zero count
instead of throwing. Empty arrays and zero counts are a normal 200 - they
mean the field is unused, which is exactly the answer the UI needs to
render “safe to delete.”
A dependency-scan response looks like this:
The force-delete flow
Deletion is a two-phase safety gate. The scan above powers both phases:- Soft delete (
DELETE /custom-fields/:idwithoutforce=true) - scans the dependents. If any surface has a match, the request returns409 CUSTOM_FIELD_IN_USEwith the full snapshot embedded in the error details, and nothing is deleted. The UI renders the snapshot in a type-to-confirm dialog so the operator sees exactly what breaks. - Force delete (
DELETE /custom-fields/:id?force=true) - confirms the operator has seen the blast radius, then deletes. Dependent segments are markedstatus = 'broken', dependent campaigns are flagged inmetadata(not their lifecycle status, so an in-flight send isn’t cut mid-recipient), dependent flows are markedbroken(IVR flows flipactive = false), and the definition’s key is stripped from every contact’s attribute blob regardless. A per-surface audit row records exactly which segments, campaigns, flows, and drip enrollments broke, so the cleanup trail is reconstructable.
Where segments and formulas pick the field up
Custom fields are first-class segment operands. The segmentation engine accepts both the canonicalcustom_attributes.<key> and the legacy
custom_attribute.<key> prefix (the field-path regex
/custom_attributes?\./ permits either), and renders each filter against
a COALESCE spanning both attribute columns, canonical-first. That means a
segment filter written against a CSV-imported field still matches
correctly once values are promoted into custom_attributes, and vice
versa - the read path is deliberately identical to the dependency scan’s
resolution rule, so “45 contacts hold this field” and “segment returns
45” agree.
PII handling
Values returned through the schema-explorer (GET /:id/explorer) are
masked by the caller’s reveal context: before any sample value leaves the
API it passes through PII redaction (redactPii), and strings longer
than 120 chars are tail-truncated with an ellipsis. Redaction is
defense-in-depth - the explorer is limited to owner/admin/developer, but
a mis-keyed custom attribute should never echo a raw phone number or
email into an operator panel.
Required-field gate
When a definition is markedrequired, every contact create or update is
gated until a value for it is present. The gate reads the definitions
store and caches it per organization (60-second TTL) with invalidation
hooks on every definition mutate - create, update, delete, and the
reorder do not affect the gate but create/update/delete do - so toggling
Required takes effect on the next write rather than after the cache
lapses. A write missing a mandatory value returns 422 instead of
persisting partial data.
The route surface at a glance
The definition controller surfaces roughly a dozen routes. Grouped by verb:
Every one of these routes is tenant-authenticated; the mutations require
owner/admin/developer.
See also
- Custom Fields guide - when to use a field, how the types and validators work, and the curl flow
- Custom Fields API reference - full endpoint list and request/response schemas
- Segments - filtering contacts by a custom field
- Consent, opt-out, and suppression model - the other tenant-owned contact-state gates