Skip to main content

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-scoped custom_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, or user_ref. Frozen at creation, alongside the key, because changing either would orphan values already stored on contacts.
  • options - the allowed list, required for select / 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, and enum_values, applied whenever a value is written. A failing value returns 422 VALIDATION_ERROR.
A typical definition reads like this:
Definitions are the schema; the actual values live on the contact.

The dual JSONB storage model

Contact custom-field values occupy two JSONB columns on the contacts table, not one:
  • custom_attributes - the canonical store. Inline edits, the PUT /custom-fields/values write, 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.
The usage counter, sample explorer, and dependency scan probe both columns, in a fixed COALESCE order - canonical 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 returns 422 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 their filters ast or the legacy rules column.
  • campaigns - campaigns referencing the key in message_template (mustache {{contact.custom.<key>}}, a TEXT scan), variables, or journey_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 their definition or trigger_config (plus published_definition for IVR).
Because a segment can reference a custom field by its dotted path (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:
  1. Soft delete (DELETE /custom-fields/:id without force=true) - scans the dependents. If any surface has a match, the request returns 409 CUSTOM_FIELD_IN_USE with 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.
  2. Force delete (DELETE /custom-fields/:id?force=true) - confirms the operator has seen the blast radius, then deletes. Dependent segments are marked status = 'broken', dependent campaigns are flagged in metadata (not their lifecycle status, so an in-flight send isn’t cut mid-recipient), dependent flows are marked broken (IVR flows flip active = 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.
That split is what makes the force path safe: the soft-check surfaces the blast radius, the type-to-confirm makes the operator acknowledge it, and the broken-flagging makes the dependents findable for cleanup instead of silently failing to match.

Where segments and formulas pick the field up

Custom fields are first-class segment operands. The segmentation engine accepts both the canonical custom_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 marked required, 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