Custom fields
Attach your own data to contacts and put it to work in templates, segment filters, and workflow logic
Alongside its built-in fields (email, subscribed, createdAt, updatedAt), every Bitelio contact carries a free-form data object. What lives there is entirely up to you: first names, plan tiers, signup dates, your own user IDs — anything you'll later want available in templates, segments, or workflow conditions.
This guide walks through the full lifecycle of custom fields: writing them, how typing works, where you can use them, and how to retire them.
Setting custom fields
You can write custom fields whenever a contact is created or updated:
# Via /v1/track (auto-creates the contact)
curl -X POST https://api.bitelio.com/v1/track \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"event": "signed_up",
"data": {
"firstName": "Ada",
"plan": "pro",
"signupDate": "2026-05-06T12:00:00Z",
"lifetimeValue": 240
}
}'
# Via PATCH /contacts/:id
curl -X PATCH https://api.bitelio.com/contacts/cnt_abc123 \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{ "data": { "plan": "enterprise" } }'Updates to data are merges, not replacements — only the keys present in your payload are touched, and everything else stays as it was.
Special values
| Value | Behaviour |
|---|---|
| Primitive | Stored. Available for templates, segments, workflows. |
null | Deletes the key from the contact. |
"" (empty) | Ignored — does not overwrite existing data. |
{ value, persistent: false } | Used for this send only; not stored on the contact. Good for one-shot codes (password resets, magic links). |
Changed August 2026
A non-persistent field is written as { value, persistent: false } and referenced as
{{ field }} — the envelope is unwrapped for you. That was already true everywhere except
workflow emails, SMS and webhooks, where until August 2026 the raw envelope reached the template
and the only thing that rendered was {{ field.value }}.
Those three now behave like the rest of the platform. If you wrote {{ field.value }} in a
workflow template as a workaround, change it to {{ field }} — the old form renders empty, and
nothing warns you.
Type inference
The first time a field appears with a non-null value anywhere in your project, Bitelio assigns it a type:
| Detected as | Examples |
|---|---|
| Number | 42, 3.14 |
| Boolean | true, false |
| Date | Strings matching YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS[.sss]Z |
| String | Anything else |
That type drives the segment builder's UI — date fields get date pickers, numeric fields get number inputs, and so on — and once established it sticks for the project. Should you later send a different type for the same key, the UI keeps honoring the original inference, though Bitelio will still store whatever value arrives.
Date-aware operators (within, olderThan) only apply to custom fields stored as full ISO 8601 strings: "2026-05-06T12:00:00Z" qualifies, "05/06/2026" does not.
Reserved keys
A handful of keys belong to Bitelio and are silently filtered out of every data payload. Sending them neither stores them nor raises an error:
id, bitelio_id, bitelio_email, email, unsubscribeUrl, subscribeUrl, manageUrl
The core contact fields — email, subscribed, createdAt, updatedAt — also live outside data. Changing email or subscribed requires the dedicated request fields rather than data.
One key sits in between: locale. It's stored inside data like any other custom field, but Bitelio also reads it to drive localization.
Using custom fields
In templates
Any field can be referenced by name as a Handlebars variable:
<p>Hi {{firstName ?? "there"}},</p>
<p>You've been on the {{plan}} plan since {{signupDate}}.</p>The ?? fallback operator is a Bitelio extension for supplying a default whenever the field is missing or null. Nested data works too — a value stored at data.profile.tier is available as {{profile.tier}}.
In segments
In segment filters, prefix the field name with data.:
{ "field": "data.plan", "operator": "equals", "value": "pro" }
{ "field": "data.lifetimeValue", "operator": "greaterThan", "value": 100 }
{ "field": "data.signupDate", "operator": "within", "value": 30, "unit": "days" }The complete operator taxonomy is documented on the Segments concept page.
In workflows
Workflow CONDITION steps take the same data. notation. And with the UPDATE_CONTACT step, a workflow can write custom fields as it runs — for instance, setting { stage: "activated" } once the welcome email has been opened.
Inspecting your custom fields
To see every field your project has ever used — built-in and custom alike — call GET /contacts/fields. Each entry reports the inferred type and what fraction of contacts carry a value:
{
"fields": [
{ "field": "email", "type": "string", "isCustom": false, "coverage": 1.0 },
{ "field": "data.plan", "type": "string", "isCustom": true, "coverage": 0.62 },
{ "field": "data.signupDate", "type": "date", "isCustom": true, "coverage": 0.95 }
]
}This is handy for auditing your schema, spotting fields that have gone stale, or populating dropdowns in a UI of your own.
For any single field, GET /contacts/fields/:field/values lists the distinct values it has taken — a natural fit for something like a "Filter by plan" selector in your dashboard.
Cleaning up unused fields
Fields have a habit of piling up over time. Two endpoints make pruning safe:
GET /contacts/fields/:field/usage— reports every segment, campaign, and workflow that mentions the field. Check this before deleting so nothing breaks.DELETE /contacts/fields/:field— strips the field from all contacts in the project.
Deletion touches contact data only. Segments and workflows that referenced the field aren't removed — their conditions on it will simply never match again. That's exactly why the usage endpoint exists: find those references first and tidy them up.
Best practices
- Choose field names you won't rename. A rename ripples into every segment, template, and workflow that mentions the field.
- Store dates as ISO 8601. Any other format locks you out of the date-aware segment operators.
- Keep secrets out of
data. Everything there is visible in the dashboard and to anyone holding API access. For one-shot codes that shouldn't stick around, use{ value, persistent: false }. - Favor flat keys when a field has many values. Nested paths like
data.profile.tierwork fine, but they're a little harder to spot in the segment UI.