Contacts

Push profiles from your system into OpenHouse. Each becomes a contact, identified by your own stable customer id (externalId) or by email, and re-sending a record safely upserts.

POST/ingest/contacts
{
  "mode": "upsert",
  "records": [
    {
      "externalId": "cus_42",
      "email": "ada@example.com",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "country": "DE",
      "consentStatus": "opted_in",
      "custom": { "company": "Acme GmbH", "cohort": "2026-Q3" }
    }
  ]
}
Record fields — all optional, but a record needs an identity: externalId and/or email
externalId
string ≤200
Your system's stable customer id, unique within your organization. Immutable once set.
email
string ≤320
Lowercased and validated. An attribute, not an identity — it can change, and two contacts may share one.
firstName
string ≤200
Non-empty values enrich; omitted fields never erase.
lastName
string ≤200
Same enrichment semantics.
country
string ≤10
Country code.
consentStatus
opted_in | opted_out | unknown
Only changes when explicitly present — ingestion can never silently resurrect an opted-out contact. New contacts without it start as unknown.
custom
object
Values for your organization's own custom fields, keyed by field key. The fields must exist first (see Custom fields below). Each value is checked against its field's type; a value that doesn't fit is reported in fieldErrors and the record still lands. null or an omitted key never erases a stored value.

Contacts also carry a read-only randomBucket (0–999), assigned by the system at creation and returned on every contact response. It is the handle for random samples and A/B arms in segments (randomBucket less_than 100 is a stable 10%). It is not accepted on ingest.

mode (batch-level, default "upsert"): with upsert, a record matching no existing contact creates one; with enrich, it is skipped with a named reason and nothing is created — for integrations that must only ever update what already exists.

Custom fields

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 via the endpoint below), and the integration then writes values into it. Ingest never creates fields on its own, so a typo in a key is a loud error rather than a new column.

GET/contacts/fields

The field registry: every 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.

POST/contacts/fields
{
  "key": "cohort",
  "label": "Cohort",
  "type": "categorical",
  "options": ["2026-Q3", "2026-Q4"]
}
key
string
camelCase identifier, 2–41 characters, letter first: ^[a-zA-Z][a-zA-Z0-9_]{1,40}$. This is the key you send in custom.
label
string ≤80
Display name in the app.
type
text | 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.
options
string[]
Required for categorical fields, 1–50 values. Not allowed on list fields — a list is open.
PATCH/contacts/:id/fields/:key
{ "value": ["de", "fr"], "mode": "add" }
value
typed
The value in the field's type; null clears it.
mode
set | 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 400 that 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.

How records match contacts

Matching is strict and predictable — no write ever merges contacts implicitly:

  • A record with externalId matches by externalId only — never by email fallback. Re-sending {"externalId": "cus_42", "email": "new@x.com"} updates that contact's email; an unmatched externalId creates a new contact even if the email matches an existing one.
  • A record without externalId matches by email: exactly one match updates it; several contacts sharing the email skip the record with a named reason (use externalId); no match creates (upsert) or skips (enrich).
POST/contacts/identify

Attaches your externalIds to contacts that exist only by email — typically once, when your integration arrives after a CSV-imported profile base. Up to 1000 records:

{ "records": [ { "externalId": "cus_42", "email": "ada@example.com" } ] }

Each record stamps the externalId onto the contact with that email only when the email matches exactly one contact that has no externalId yet. Anything else — id already assigned, email matches nobody or several contacts, the contact already carries a different id — is a named skip, never a merge. Re-running the same pairs skips harmlessly.

Addressing a single contact

Everywhere a contact id appears in a path you can use either the OpenHouse uuid or ext: + your externalId — no id-lookup round trip needed:

GET /contacts/ext:cus_42          # the contact, by your id
GET /contacts/ext:cus_42/events   # their event timeline

An unknown externalId is a 404, exactly like an unknown uuid.

Erasure and export

Both require an administrator's key and accept either address form.

GET/contacts/ext:cus_42/export

Everything OpenHouse holds about one person as a single JSON bundle — the contact, their custom field values, their events and their email engagement — for access and portability requests.

DELETE/contacts/ext:cus_42
DELETE /contacts/ext:cus_42          # by your externalId
DELETE /contacts/3f2a…-…               # or by the OpenHouse id

# 200 → { "ok": true }     # 404 for an unknown id, 403 without an administrator's key

A hard delete: the contact, their field values, events and automation enrollments go with the row, and campaign statistics keep only anonymous counts. The action is written to your audit log with the contact's id only — never the email. Nothing remembers the erasure — a list of "people we deleted" would itself store what they asked us to remove — so re-ingesting the same person afterwards creates a brand-new contact with fresh consent. Keeping erased people out of future batches is your system's job, at the source.