Skip to main content
A contact is one of your users. It starts as an anonymous visitor and becomes identified when a strong identity attaches to it. Contacts live in an environment. Everything below is scoped to one.

Identity edges

A contact is reached through identity edges — values that resolve to it. There are three kinds: That difference is the whole model. external_id and email are identifying: a value that already belongs to another contact is a duplicate to merge by hand, never a silent re-point. Silently moving an email between contacts is how one person ends up reading another’s data. anonymous_id is the opposite: a device cookie belongs to whoever signed in last, so the last claimant takes it.

Identify

POST /v1/contacts is create-or-update. Send whatever your backend knows; at least one of external_id, email or anonymous_id is required.
201 when the contact was created, 200 when it already existed — both with created in the body so you do not have to read the status. Resolution order is external_id, then email: your own primary key is the stronger claim. Name and email are attributes. A change is a change, not a new person — they are updated on every call. The email identity edge is only attached when it is free. A conflict leaves the edge where it is and hands you a duplicate to resolve.
A call carrying only an anonymous_id is a visitor sighting, not an identification. identified stays false.

Folding the visitor in

When an anonymous_id arrives alongside an identifying kind, the visitor contact it belonged to is folded into the identified one — automatically, in the same transaction. That is the moment attribution earns its keep: the campaign that brought someone to your landing page was recorded on the anonymous row, weeks before they had a name, and it survives into the contact you now know.

Attribution

First touch, captured on the visitor at first load and preserved through every merge:
All fields optional; empty means unknown. It is first touch, not last — it does not move once set. Send it on /v1/boot (from the page, where the UTMs actually are) or on /v1/contacts from your server.

Identified, and verified

Two different flags, and the difference matters.
boolean
This contact is more than a visitor — an external_id attached, or an email was proven.
boolean
The address was proven, by a link arriving in it: a verification link, a password-reset link, or a magic link.
Passing an email to the API records an attribute. It does not prove one. Anyone can type an address; only a message arriving in it is evidence. In federated mode this is structural: an email arriving through /v1/boot is stored on the row and never becomes an identity edge, because the HMAC proves the external_id and only the external_id. A valid hash for your own id plus someone else’s email must never touch that someone’s contact. There is a third verified, on the contact session, which says the identity behind that session was proven. Related, not the same.

Merges

There is no un-merge. So merges happen on purpose.
Direction is deliberately explicit: {id} in the URL is the source — the row that disappears — and target_id in the body is the survivor. Requires customers:write. Every merge writes a record carrying who did it, when, and what moved:
That provenance is what makes a wrong merge repairable by hand. Merging a contact into itself answers 400. Automatic merges do happen — folding a visitor into the contact that just claimed its anonymous_id — and they are recorded the same way, with a different kind.

Reading contacts

Two surfaces, two postures:
Pagination is limit (default 50, capped at 200) and offset. The cap is deliberate: enough for a screen, small enough that nobody accidentally downloads their whole audience.