Attributes
Beyond the base record, a profile carries whatever fields your organization has defined: a company, a cohort, an account manager. Your workspace owns that schema. A field is created once, by an admin in the app or through the API, and your integration then writes values into it.
Values arrive in the custom object of a record on /ingest/contacts, keyed by field key. Ingest never creates fields on its own, so a typo in a key is a loud error rather than a new column.
The field registry
/contacts/fieldsEvery field with its key, label, type and, for categorical fields, options. Only fields with "source": "manual" can be written by ingest. The others are computed — event aggregations (lifetime revenue, last session…), calculated fields, scores — and are recalculated by OpenHouse; writing to one is refused, because the next recalculation would silently overwrite it.
Creating a field
/contacts/fields{
"key": "cohort",
"label": "Cohort",
"type": "categorical",
"options": ["2026-Q3", "2026-Q4"]
}keystring | camelCase identifier, 2–41 characters, letter first: ^[a-zA-Z][a-zA-Z0-9_]{1,40}$. This is the key you send in custom. |
labelstring ≤80 | Display name in the app. |
typetext | number | boolean | date | categorical | list | text ≤2000 chars; number is a JSON number; boolean is true/false; date is an ISO 8601 string; categorical is one of options (exact match); list is an array of strings — an open SET (unique, each ≤80 chars, at most 100) for attributes with no date or history, like languages or tags. Something a profile joins or does over time is an event, not a list. |
optionsstring[] | Required for categorical fields, 1–50 values. Not allowed on list fields — a list is open. |
Writing one value
/contacts/:id/fields/:key{ "value": ["de", "fr"], "mode": "add" }valuetyped | The value in the field's type; null clears it. |
modeset | add | remove | list only. set (the default, and the only mode for every other type) replaces the stored list; add puts the given values in — unique, a re-added value moves to the end, and past 100 values the oldest are dropped; remove takes them out, and a list emptied this way is cleared. Both are applied in the database, so two writers never overwrite each other's edits. |
In custom on /ingest/contacts a list value is always the full list ("languages": ["de", "fr"]) and replaces what is stored — an ingest record carries the whole profile. In a CSV the cell is de; fr (split on ;). Values are stored as sent: en and EN are two values, so agree on a spelling in your integration.
What happens to a bad custom value
- A key that is unknown, computed or archived fails the whole request with a
400that names the field and which of the three it was. Keys are schema: nothing is written until every key in the batch resolves. - A value that doesn't fit the field's type ("12" for a number field, an option that isn't in the list) is reported per record in
fieldErrors—[{ "index": 1, "field": "cohort", "reason": "Value must be one of: …" }]— while the record itself, its base fields and its other custom values still land. A bad cell is not a bad row. null, an empty string, or a key left out never erase what is stored — the same rule as the base fields. Clearing a value is a deliberate act in the app.