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.
/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" }
}
]
}externalIdstring ≤200 | Your system's stable customer id, unique within your organization. Immutable once set. |
emailstring ≤320 | Lowercased and validated. An attribute, not an identity — it can change, and two contacts may share one. |
firstNamestring ≤200 | Non-empty values enrich; omitted fields never erase. |
lastNamestring ≤200 | Same enrichment semantics. |
countrystring ≤10 | Country code. |
consentStatusopted_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. |
customobject | Values for your organization's own custom fields, keyed by field key. The fields must exist first (see Attributes). 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.
Which keys custom accepts, how to create a field, the value types, and what happens to a value that doesn't fit are on Attributes.
How records match contacts
Matching is strict and predictable — no write ever merges contacts implicitly:
- A record with
externalIdmatches 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
externalIdmatches by email: exactly one match updates it; several contacts sharing the email skip the record with a named reason (useexternalId); no match creates (upsert) or skips (enrich).
/contacts/identifyAttaches 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 timelineAn unknown externalId is a 404, exactly like an unknown uuid.
Erasure and export
Both require an administrator's key and accept either address form.
/contacts/ext:cus_42/exportEverything 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.
/contacts/ext:cus_42DELETE /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 keyA 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.