---
title: Identifying users
description: Link an anonymous visitorId to a logged-in userId with POST /api/v1/identify, and understand what it does and does not unlock.
---

`POST /api/v1/identify` is a dedicated endpoint for associating an anonymous
`visitorId` with a logged-in `userId`, optionally attaching user traits. Call it
once a visitor logs in or completes registration.

<Note>
  This is a separate endpoint from event ingestion — it is **not** a Segment-style
  `POST /api/v1/t` call with `{ "type": "identify" }`. The ingest endpoint has no
  `type` field at all.
</Note>

## Prerequisites

- A `visitorId` already produced by [Sending events](/docs/sending-data/events) —
  identify only links an ID that already exists, it does not create one.

## Endpoint

```
POST https://api.retidal.com/api/v1/identify
```

Authenticate with `X-API-Key` (also accepted as `?_ak=` / `?key=`).

## Request body

<ParamField name="visitorId" in="body" type="string" required>
  The anonymous visitor ID to associate. Alias `visitor_id`.
</ParamField>
<ParamField name="userId" in="body" type="string" required>
  The logged-in user ID. Alias `user_id`.
</ParamField>
<ParamField name="traits" in="body" type="object">
  Optional user attributes — see [trait rules](#trait-rules) below.
</ParamField>

```json title="Request"
{
  "visitorId": "vid_abc123",
  "userId": "uid_456",
  "traits": {
    "email": "user@example.com",
    "phone": "+8613800138000",
    "name": "张三",
    "locale": "zh-CN",
    "timezone": "Asia/Shanghai",
    "plan": "pro"
  }
}
```

Both `visitorId` and `userId` are required on `POST /api/v1/identify` — omit either
one and you get `400`.

## Trait rules

<Steps>
  <Step title="Reserved keys become encrypted columns">
    `email`, `phone`, `name`, `locale`, and `timezone` are extracted to
    encrypted top-level columns on the user record. Send an explicit `null` to
    clear one of these fields.
  </Step>
  <Step title="Everything else is a custom trait">
    Any other key (`plan`, `company`, …) is stored as JSON.
  </Step>
  <Step title="Dangerous keys are dropped, not stored">
    Keys whose name contains a dangerous substring — `password`, `pwd`, `idcard`,
    `passport`, `ssn`, `bankcard`, `cvv`, `token`, `secret`, `private_key`,
    `api_key`, and similar — are silently dropped from storage and echoed back in
    the response's `dropped_keys` array so you know what didn't make it.
  </Step>
  <Step title="Size and depth limits apply">
    On `POST /api/v1/identify`, the serialized `traits` payload must be **≤ 8 KiB**
    and nested **≤ 2 levels**. Exceeding either returns `400`.
  </Step>
</Steps>

Audit logs record only trait key counts, never trait values.

## Response

`POST /api/v1/identify` success returns `202`:

```json
{ "ok": true, "dropped_keys": [] }
```

`dropped_keys` lists any trait keys that were filtered out by the dangerous-key
check above — an empty array means every trait you sent was accepted.

## Errors

| Status | Meaning |
| --- | --- |
| `400` | Invalid JSON, missing `visitorId`/`userId`, or `traits` failed the size/depth/shape check (`POST /api/v1/identify`). |
| `401` | Missing or invalid API key (`POST /api/v1/identify`). |
| `500` | Internal error while persisting the identity (no details leaked) — `POST /api/v1/identify`. |

## What happens after identify

Calling identify has a side effect: Retidal **backfills** the `userId` onto every
event and visit this `visitorId` produced before the identify call. So pre-login
attribution — clicks, page views, cart activity — gets stitched to the identified
user retroactively, without you having to re-tag historical events.

<Warning title="Event-triggered email needs identify + email first">
  For a user to receive an event-triggered email, they must already be identified
  with a non-empty `traits.email`. The trigger consumer looks up the user by
  `userId` at send time — if there's no identified user at all, it skips with
  `no_identified_user`; if the user was identified but has no email on file, it
  skips with `no_email`. Both cases fail silently from your side (only the
  trigger log shows the skip reason). If you're building a "send an invoice after
  payment" flow, call `identify` (with `traits.email`) **before** you report the
  paid event, not after.
</Warning>

## Code sample

```javascript title="JavaScript"
async function identify(visitorId, userId, traits) {
  const response = await fetch("https://api.retidal.com/api/v1/identify", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": "your-api-key",
    },
    body: JSON.stringify({ visitorId, userId, traits }),
  });
  return response.json();
}

// After a successful login
await identify("vid_abc123", "uid_456", { email: "user@example.com", plan: "pro" });
```

## What this unlocks

Calling identify unlocks [event-triggered email](/docs/email/triggers) (which needs an
identified user with an email on file) and correct
[cross-device attribution](/docs/attribution/cross-device) reporting, since both key off
`userId` rather than the anonymous `visitorId` alone.

## Verify it worked

```bash
curl -s -X POST https://api.retidal.com/api/v1/identify \
  -H "X-API-Key: $RETIDAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"visitorId":"vid_abc123","userId":"uid_456","traits":{"email":"user@example.com"}}'
# expect: 202 { "ok": true, "dropped_keys": [] }
# an empty dropped_keys array confirms every trait you sent was accepted, not filtered
```

## If it doesn't work

- `POST /api/v1/identify` itself rejected (`400`/`401`/`500`) — work through
  [Events not arriving](/docs/troubleshooting/events-not-arriving); this endpoint shares
  the ingestion-adjacent failure modes of `POST /api/v1/t`.
- The call succeeds but the visitor/user link never seems to merge (pre-login activity
  isn't showing up under the identified user) — work through
  [Attribution looks wrong](/docs/troubleshooting/attribution-looks-wrong).

## Next steps

<CardGroup cols={2}>
  <Card title="Sending events" href="/docs/sending-data/events">
    Report the events these identified users produce.
  </Card>
  <Card title="Cross-device attribution" href="/docs/attribution/cross-device">
    See exactly what identity resolution guarantees — and what it doesn't.
  </Card>
</CardGroup>
