# Custom fields (/guides/custom-fields)

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:

```bash
# Via /v1/track (auto-creates the contact)
curl -X POST https://bitelio-api.heltia.com/v1/track \
  -H "Authorization: Bearer pk_..." \
  -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://bitelio-api.heltia.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). |

<Callout type="warn" title="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.
</Callout>

## 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](/guides/localization).

## Using custom fields

### In templates

Any field can be referenced by name as a Handlebars variable:

```html
<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.`:

```json
{ "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](/concepts/segments).

### 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:

```json
{
  "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.tier` work fine, but they're a little harder to spot in the segment UI.
