> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Contacts

> Your users: visitors, identities, first-touch attribution, and merges you do on purpose.

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:

| Kind           | What it is                           | On conflict            |
| -------------- | ------------------------------------ | ---------------------- |
| `external_id`  | Your own primary key for this person | Never re-points        |
| `email`        | An address                           | Never re-points        |
| `anonymous_id` | A device or browser id               | **Last claimant wins** |

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.

```bash theme={null}
curl -s $API/v1/contacts/{id} -H "Authorization: Bearer $UK_KEY"
```

```json theme={null}
{
  "contact": {
    "id": "3f9a…",
    "identified": true,
    "email_verified": false,
    "identities": [
      { "kind": "external_id",  "value": "user_8421",         "created_at": "…" },
      { "kind": "email",        "value": "grace@example.com", "created_at": "…" },
      { "kind": "anonymous_id", "value": "anon_2f9c1b",       "created_at": "…" }
    ]
  }
}
```

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

```bash theme={null}
curl -s $API/v1/contacts \
  -H "Authorization: Bearer $UK_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "external_id": "user_8421",
    "email": "grace@example.com",
    "anonymous_id": "anon_2f9c1b",
    "name": "Grace Hopper"
  }'
```

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

<Note>
  A call carrying only an `anonymous_id` is a visitor sighting, not an
  identification. `identified` stays `false`.
</Note>

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

```json theme={null}
"attribution": {
  "utm_source": "newsletter",
  "utm_medium": "email",
  "utm_campaign": "july-launch",
  "utm_term": null,
  "utm_content": null,
  "referrer": "https://news.ycombinator.com/",
  "landing_page": "https://example.com/pricing"
}
```

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.

<ResponseField name="identified" type="boolean">
  This contact is more than a visitor — an `external_id` attached, or an email was
  proven.
</ResponseField>

<ResponseField name="email_verified" type="boolean">
  The **address** was proven, by a link arriving in it: a verification link, a
  password-reset link, or a magic link.
</ResponseField>

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](/en/concepts/credentials#contact-sessions),
which says the identity behind *that session* was proven. Related, not the same.

## Merges

There is no un-merge. So merges happen on purpose.

```bash theme={null}
curl -s "$API/v1/organization/contacts/{source_id}/merge?environment=live" \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…" \
  -H 'Content-Type: application/json' \
  -d '{ "target_id": "…" }'
```

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:

```json theme={null}
{
  "merge": {
    "id": "…",
    "source_contact_id": "…",
    "target_contact_id": "…",
    "kind": "manual",
    "moved": { "...": "..." },
    "created_at": "…"
  }
}
```

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:

<CodeGroup>
  ```bash Your server — the key's environment theme={null}
  curl -s "$API/v1/contacts?limit=50&offset=0" \
    -H "Authorization: Bearer $UK_KEY"
  ```

  ```bash The panel — an explicit view theme={null}
  curl -s "$API/v1/organization/contacts?environment=test&limit=50" \
    -H "Authorization: Bearer $UK_SESSION" \
    -H "X-Organization-Id: org_4b1e…"
  ```
</CodeGroup>

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.
