> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/core-concepts/identities.md).

# Identities

Millimetric uses two identifiers for "who did this", and they're independent on purpose:

| Identifier     | Lifecycle                           | Lives in                                                    | Set by                                    |
| -------------- | ----------------------------------- | ----------------------------------------------------------- | ----------------------------------------- |
| `anonymous_id` | Per device / browser, persistent    | `localStorage` (browser) or your own cookie/header (server) | Caller — the SDK generates it client-side |
| `user_id`      | Per *person*, stable across devices | Your auth system                                            | You — once a visitor authenticates        |

Every event must be attributable to **at least one of them**. Both is great. Neither isn't allowed (the server will fabricate an `anonymous_id` if you really don't send one — but you've thrown away your ability to re-link the visit).

## anonymous\_id

A pseudonymous, per-device UUID. The browser SDK creates one on first load and stores it under `localStorage["mm_aid"]`. It survives refreshes, tab closes, and SPA navigations. It does **not** survive:

* Clearing browser storage.
* Switching browsers.
* Switching devices.
* Private / incognito sessions (the SDK falls back to an in-memory UUID for the tab's lifetime).

That's a feature, not a bug — `anonymous_id` is meant to be *low-stakes*. If a visitor wipes their data, the link is severed.

```ts
import { getAnonymousId, setAnonymousId } from "@millimetric/track";

getAnonymousId();              // "u_8f3c1a..."
setAnonymousId("u_custom_42"); // override (e.g. from server-rendered HTML)
```

Server-side, you supply your own anonymous id — usually from a cookie you set yourself or a request header you forward from the browser:

```ts
track({
  event: "form_submit",
  anonymous_id: req.cookies.aid,    // ← your job to pass this through
  user_id: user.id,
  properties: { form: "contact" }
});
```

## user\_id

Your stable, internal user id. Same one you use in your own database. Once known, you set it on every subsequent `track`.

The browser SDK stores `user_id` after `identify()` and merges it into every event:

```ts
import { identify, track } from "@millimetric/track";

identify(user.id, { email: user.email, plan: user.plan });
track("clicked_pricing");   // automatically tagged with user_id
```

The Node SDK doesn't have local state — pass `user_id` explicitly on each call.

## /v1/identify — stitching them together

Calling [`POST /v1/identify`](/api-reference/identify.md) emits a special `$identify` event that links a known `user_id` to the visitor's `anonymous_id`:

```bash
curl -X POST https://api.millimetric.ai/v1/identify \
  -H "Authorization: Bearer $SK_KEY" \
  -d '{
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "traits": { "email": "matt@example.com", "plan": "pro" }
  }'
```

What this changes:

* A `$identify` event lands in ClickHouse with `event_name='$identify'`, `anonymous_id`, `user_id`, and `traits` in `properties`.
* Subsequent events for that `anonymous_id` should also send `user_id` (the SDK does this automatically).
* **Historical events for the same `anonymous_id` are not retroactively rewritten.** They still have `user_id = NULL`. To stitch them in analysis, JOIN through the `$identify` event or use the `sessions` view.

## When to call identify

| Moment                      | Call identify?                                                           |
| --------------------------- | ------------------------------------------------------------------------ |
| User signs up               | **yes** — you finally know who they are                                  |
| User logs in (returning)    | **yes** — confirm the link on this device                                |
| User logs out               | **no** — keep tracking events anonymously, do not flip `user_id` to null |
| User switches account       | **yes** — call identify with the new `user_id`                           |
| Visitor browses anonymously | **no** — no need until they identify themselves                          |

Don't be afraid to call `identify` more than once. It's idempotent at the event level — each call just emits another `$identify`. Some teams call it on every authenticated request handler so every device the user touches gets a fresh stitch.

## The pre-/post-login stitch

The killer use case. A visitor lands from a Facebook ad, browses for a week, finally signs up.

```
Day 1, 14:00  $pageview        anonymous_id=u_abc, user_id=NULL
                                 source=facebook, medium=paid
Day 1, 14:02  clicked_pricing  anonymous_id=u_abc, user_id=NULL
Day 5, 09:30  $pageview        anonymous_id=u_abc, user_id=NULL
Day 7, 11:14  $identify        anonymous_id=u_abc, user_id=user_42
                                 (← here you call /v1/identify)
Day 7, 11:14  signup           anonymous_id=u_abc, user_id=user_42
```

For revenue attribution, you want to credit the Day 1 paid Facebook click for the Day 7 signup. Two approaches:

**1. Per-session attribution (recommended).** The `sessions` materialised view captures `entry_source / entry_medium` per `(anonymous_id, 30-min window)`. `user_id` shows up on the `$identify` event itself. JOIN sessions to identifies to credit the *first* session that brought this user in.

**2. Per-user attribution.** Find the earliest event for `anonymous_id = u_abc` regardless of `user_id`:

```sql
SELECT
  user_id,
  argMin(source, timestamp) AS first_touch_source,
  argMin(medium, timestamp) AS first_touch_medium
FROM events
WHERE project_id = '...'
  AND anonymous_id IN (
    SELECT DISTINCT anonymous_id
    FROM events
    WHERE user_id = 'user_42' AND project_id = '...'
  )
GROUP BY user_id;
```

(See the [Anonymous → known](/recipes/anonymous-to-known.md) recipe for a fleshed-out version.)

## traits

`traits` on `/v1/identify` are stored in the `$identify` event's `properties`. They're *the user's attributes at the moment they identified* — not a profile that lives elsewhere. There's no "user store"; if you need the latest plan, query your own DB.

```json
{ "anonymous_id": "u_abc", "user_id": "user_42",
  "traits": { "plan": "pro", "team_size": 4, "signup_at": "2026-05-16T11:14:00Z" } }
```

Best practice: include just enough on `traits` to enable cohort filtering in event queries (`plan`, `tier`, `team_size`). For the source of truth, query your own DB.

## A device with multiple users

The SDK stores **one** `user_id` at a time. If user A logs out and user B logs in on the same browser:

```ts
// user A logs out — your code's choice
// (recommended: leave the user_id stale; subsequent tracks until B identifies will look like A)

identify(userB.id);   // overwrites the stored user_id; emits $identify for B
```

Anonymous events between the logout and the next identify will be tagged with whichever `user_id` was last set. If that bothers you, call `setAnonymousId(crypto.randomUUID())` between users to force a fresh anonymous identity.

## A user across multiple devices

Each device has its own `anonymous_id`. As soon as the user identifies on each device, you'll have:

* `user_42` ↔ `u_abc` (laptop)
* `user_42` ↔ `u_xyz` (phone)

The events on both devices share `user_id = user_42`. Per-user analytics (`group by user_id`) works seamlessly. Per-device analytics (`group by anonymous_id`) splits them, which is correct.

## Forgetting

[`POST /v1/forget`](/api-reference/forget.md) deletes events by `(project_id, user_id)`. It does **not** touch events that were emitted before identify (`user_id = NULL`). Those are indistinguishable from any other anonymous traffic — by design.

If you need to forget by `anonymous_id`, run the SQL directly:

```sql
ALTER TABLE events DELETE
  WHERE project_id = '{project_id}'
    AND anonymous_id = '{anonymous_id}';
```

## See also

* [POST /v1/identify](/api-reference/identify.md) — the endpoint.
* [Sessions](/core-concepts/sessions.md) — how anonymous activity gets bucketed.
* [Anonymous → known users recipe](/recipes/anonymous-to-known.md).
* [GDPR delete recipe](/recipes/gdpr-delete.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/core-concepts/identities.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
