Skip to main content

Custom Fields

Orbit’s contact record covers what every business needs (name, phone, email, channel preferences). The moment you need something specific to your business — plan_tier, lifetime_value, account_owner, renewal_date — you define a custom field instead of stuffing it into a note or an external spreadsheet. Once defined, the field behaves like a first-class contact attribute: it shows up on the contact record, it’s filterable in segments, it’s usable as a condition in flows, it drives campaign audience filters and variables, and it can be dropped into a message with {{contact.custom.plan_tier}}-style personalization. This guide is the practical companion to the Custom Fields API reference. The reference lists every request/response shape; this page walks through per-type recipes, the validator matrix, the lifecycle of a definition, and the limits to plan around.

Why custom fields instead of notes or a spreadsheet

A freeform note and a synced spreadsheet both fail the same way: the data exists but nothing can act on it. Reach for a custom field when a piece of contact data needs to be:
  • Structured — typed and validated at write time, not a freeform note. A plan_tier select can never drift into “Enterprise” vs “enterprise” vs “ENT”.
  • Queryable — you want to build a segment like “plan_tier = enterprise AND lifetime_value > 5000” and have it evaluate correctly for every contact, every time.
  • Reusable — the same value drives a flow condition, a campaign filter, and a personalization token, and you don’t want three separate places to keep it in sync.
  • Actionable — segments, flows, and campaigns all read custom fields directly. A note is prose a human can read; a custom field is a fact the platform can act on.
If you only need to leave a one-off annotation on a contact, use the contact’s notes instead. A custom field is for data your workflows act on — not for prose.

The seven type families

Every field has a type fixed at creation. Pick by what you’ll do with the value, not by what the data looks like today — if you’ll ever filter on “greater than”, it needs to be a number, not a text. Sections 2a–2g give a full recipe per type: definition, value write, and where it shows up.

2a. text — free-form strings

Use text when values can’t be enumerated in advance: an external CRM id, an account manager’s display name, a support PIN.
A text value that doesn’t match validator_regex, or falls outside min_len/max_len, is rejected with 422 at write time. The regex is anchored to the whole value (HTML5 pattern semantics) — a pattern like [0-9]{6} rejects xx123456yy instead of matching the six digits inside it.

2b. number — range-filterable numerics

Use number whenever you’ll ever ask “greater than X” in a segment or compare values in a flow. Strings sort lexicographically; numbers sort numerically — this choice is unfixable later, so make it now.
On a number field, min_len/max_len are the inclusive numeric range — min_len: 0 above rejects negative values. A numeric string like "482.50" is coerced to a number at write time; a non-numeric value is rejected with 422.

2c. boolean — two-valued flags

Use boolean for flags a workflow branches on: VIP status, beta enrollment, do-not-disturb. Booleans accept strict true/false, and coerce the common string shorthands ("true", "1", "yes" / "false", "0", "no").
Validating at write time matters here: a boolean stored as the string "false" comes back through JSON as a truthy value and would flip a segment’s logic. The type gate keeps every stored value a real JSON boolean.

2d. date — calendar dates and renewal windows

Use date for anything you’d phrase as “before” or “after”: renewal date, contract signed date, last demo date. Values are accepted as ISO 8601 dates or datetimes and normalized to an ISO string, so every contact stores the same shape.
A typical pairing: segment on renewal_date within the next 30 days, feed that segment to a flow, and render the actual date back in the message with {{contact.custom.renewal_date}}.

2e. select — one value from a fixed list

Use select when a contact holds exactly one value from an enumerated set: plan tier, lifecycle stage, billing region. select requires an options array at creation; writing a value not in options is rejected.
The option list is a select’s only value constraint: validator_regex, min_len, and max_len are not applied to a select’s values (the option membership check handles everything). Keep option values short, lowercase, and stable — they appear verbatim in segment filters and personalization output. A select shows up in all four applications at once: a segment filter (plan_tier = enterprise), a flow branch (route enterprise renewals to the account team), a campaign audience, and a message token ({{contact.custom.plan_tier}}).

2f. multiselect — several values from a fixed list

Use multiselect when a contact holds a subset of a fixed set: interests, product lines, communication topics. The value is an array of strings, every entry drawn from options.
On a multiselect, min_len/max_len bound the number of selections (array length), not the characters. Unlike select, per-entry validator_regex and enum_values do apply — every entry in the array must satisfy them. An entry outside options is rejected with 422. Multiselect has one gap to plan around: it has no default_value (the default is stored as a scalar, which can’t represent an array), and the default-backfill skips multiselect definitions for the same reason.

2g. user_ref — a reference to a teammate

Use user_ref to model ownership: account_owner, support_rep, success_manager. The value is a string — the id of the teammate — and it validates like a text field (string-shaped, subject to min_len/max_len, validator_regex, and enum_values).
Ownership fields pair naturally with routing: segment every contact owned by a given usr_ id, and use it as a flow condition so an inbound conversation from an owned contact lands with its account owner first.

The validator matrix

Validators layer on top of the type system and apply whenever a value is written — API, dashboard edit, or CSV import. A value that fails is rejected with 422 VALIDATION_ERROR; nothing partially written ever reaches a contact. Field-by-field notes:
  • validator_regex — a JavaScript regex (no flags), anchored to the whole value. Max 200 characters. Patterns that match a known ReDoS shape (nested quantifiers, alternation under a quantifier) are rejected at save with 400 — fix alternation like (a|b|c)+ into a character class like [abc]+. Each match attempt is time-boxed, so a pathological pattern can’t stall a write.
  • min_len / max_len — inclusive bounds, interpreted per row of the matrix. Supply both and max_len must be ≥ min_len. Inclusive upper bounds sit under a global ceiling: no single value can exceed 8,192 characters regardless of max_len.
  • enum_values — an exact allow-list of up to 500 entries (200 chars each). On number / boolean / date definitions it’s rejected at save — use min_len/max_len for range bounds there. select and multiselect normally rely on options; enum_values is a second, independent gate you can stack on text or user_ref where there’s no option list.
  • select is self-constraining — the options membership check is the only value constraint a select needs, so validator_regex and length bounds are skipped for select values even if a stale one lingers on the definition.
Validators are re-checked when the definition itself is created or edited, not just when values are written: a default_value that violates its own type or validators is rejected at save, and an update that would leave the stored default violating the new validators is rejected too. This keeps the default (which backfills onto existing contacts) from seeding invalid data past the write gate. Pass null for a validator on PATCH to clear it; omit the key to leave it untouched.

Operating definitions: the full lifecycle

Definitions are created, read, updated, and deleted through /api/v1/custom-fields; values ride on contacts through /api/v1/custom-fields/values and come back inline on the contact read. Two write semantics to internalize:
  1. Writes are atomic per contact. PUT /values sets exactly one key; it never round-trips the contact’s whole attribute map, so two integrations can safely update different fields on the same contact concurrently.
  2. Passing null (or empty) as a value removes the key from the contact — unless the field is required, in which case the write is rejected. The bulk CSV-import path follows the same rules, and a value that fails validation against its definition rows as invalid row-by-row without aborting the rest of the batch.
Every definition write (create / update / delete / reorder) and every value write lands in the audit log with the actor, the field key, and — on a forced delete — the full list of dependents that were flagged.

Dependencies and the explorer: retire a field without breaking anything

A custom field accumulates dependents fast: segments filter on it, campaign templates personalize with it, flows branch on it, contacts carry values. Deleting it blindly turns every dependent filter into a “match nothing” — silently. Two tools keep you ahead of that.

The dependency scan

The response names every surface that references the field: contacts_with_data (contacts carrying a value), plus the specific segments, campaigns, and flows (both automation flows and IVR flows) that reference it, and the count of active or paused drip_enrollments tied to those campaigns. Run this first whenever you’re considering a deletion or a repurposing.

The explorer

The explorer answers “what are people actually storing here?” — the definition, the same usage block as the dependency scan (plus a total), and up to ten of the most recently written distinct values, each PII-redacted and truncated to 120 characters. Use it before you narrow an option list or tighten a validator: if the samples show values you’re about to make illegal, retire the field instead of breaking writes for existing data.

The forced deletion flow

DELETE is guarded by the dependency scan. If anything references the field, the request fails with 409 CUSTOM_FIELD_IN_USE instead of silently breaking a live segment or automation, and the error’s details.dependencies carry the same snapshot the scan endpoint returns:
The planned retirement sequence is:
  1. Scan the dependencies and read the specifics — which segments, which campaigns, which flows.
  2. Clean up or accept — either remove the references first (preferred for live automations) or decide which dependents you’ll rebuild after.
  3. Force delete when you’re ready:
With force=true, the delete proceeds and each dependent is visibly flagged rather than silently broken:
  • Segments are set to status: "broken" so they stop matching until you edit the filter.
  • Flows (automation and IVR) are marked broken — the automation runner skips them, so an orphaned condition can’t fire.
  • Campaigns are not interrupted — an in-flight send finishes — but they get a broken_custom_fields marker in their metadata so you can find and fix them.
  • Contacts lose the key from their attributes as part of the same operation, and the filter JSON in each dependent is left intact (only flagged) — rebuild it explicitly rather than inheriting a stale reference.
A field with zero dependencies deletes without force on the first request.

Limits to plan around

Definitions create whenever the payload passes validation — per-tenant definition count is bounded by practical UI ordering rather than a hard cap; keep the set focused and use reorder (POST /custom-fields/reorder) to control how fields appear in the dashboard pickers.

Lifecycle rules: what’s locked and how to migrate

Two properties are locked at creation, and this is a deliberate data-integrity rule, not a gap:
  • key is frozen. The key is the lookup inside every contact’s attribute map, every segment filter, and every personalization token. Changing keys would orphan all three at once.
  • type is frozen. Stored values were validated and coerced against the original type — a number flipped to text would silently reinterpret every existing value (and mis-sort every range filter).
Everything else stays editable through PATCH: name, description, options (you can append a plan tier to a select), default_value, required, searchable, and all validators. When you do need a new type or key, treat it as a migration, not an edit:
  1. Create the replacement field (plan_tier_v2 of the new type). The dependency scans on the old field show you exactly what points at it.
  2. Backfill values by reading the old field from each contact (GET /contacts?filter=...) and writing the new one with PUT /custom-fields/values. For large audiences, use the bulk contact update operation rather than per-contact calls, or re-run the CSV import against the new field.
  3. Repoint dependents — edit each segment filter, campaign variable, and flow branch the dependency scan listed.
  4. Retire the old field — delete it (force only if you skipped step 3), then optionally reorder the remaining definitions.
One lifecycle comfort: a field’s default_value is backfilled onto every existing contact that doesn’t yet carry the key, both at creation and when you change the default later — contacts that never had a value get it, and values anyone already set are never overwritten. (multiselect is the exception noted in its recipe above.)

Ownership, access, and where this data is controlled

Custom-field definitions are tenant-owned data in the strictest sense: they live in your tenant’s schema, they’re writable only by your owner, admin, and developer roles, and every definition or value change is recorded in your own audit log. Reads against definitions and values ride on the same API-key or session authentication as every other contact endpoint. Compliance handling for the values is yours — put into a custom field only what your retention policy allows, and when a field must go, the delete path strips its values from every contact in the same operation.

See also