# Associate server-verified identity

`POST /api/v1/identify` · operationId `identifySession`

For developers: your server sends this request with a server key; it is not a button in the dashboard.

Derive IDs from your authenticated session and account membership. Replacing established identity requires a fresh browser session. No automatic retries.

## Authentication

Send `Authorization: Bearer YOUR_SERVER_KEY` (siteServerKey: Site server key in Authorization: Bearer <key> (the scheme is case-insensitive and required; a bare key is 401). Shown once on creation or rotation. Publishable keys cannot authenticate). Keep the server key in server-only configuration; never send it to a browser.

## Request body

Content-Type: application/json (required)

- `sessionToken` (string, required) · minLength 1, maxLength 250: Opaque browser session token; do not log.
- `userId` (string | null, optional) · minLength 1, maxLength 512, pattern `\S`: Stable authenticated ID. Maximum 512 Unicode code points for server-verified identity (browser-supplied IDs stay at 128 UTF-16 code units); whitespace is trimmed.
- `accountId` (string | null, optional) · minLength 1, maxLength 512, pattern `\S`: Stable authenticated ID. Maximum 512 Unicode code points for server-verified identity (browser-supplied IDs stay at 128 UTF-16 code units); whitespace is trimmed.
- `accountTraits` (object, optional): Optional owner-supplied labels for accountId (requires accountId): a display name, a plan, your CRM account ID, a segment and the account owner's email domain (the domain only; an email address is refused), used for dashboard display, filters and exports only. Never sent by the browser SDK, never used for verdicts or authorization, removed when the account is erased. null clears a field.
- `accountTraits.name` (string | null, optional) · minLength 1, maxLength 80
- `accountTraits.plan` (string | null, optional) · minLength 1, maxLength 40, pattern `^[\p{L}\p{N} ._+-]+$`
- `accountTraits.crmId` (string | null, optional) · minLength 1, maxLength 64, pattern `^[A-Za-z0-9._:/#-]+$`
- `accountTraits.segment` (string | null, optional) · minLength 1, maxLength 40, pattern `^[\p{L}\p{N} ._+-]+$`
- `accountTraits.ownerDomain` (string | null, optional) · minLength 3, maxLength 253: A domain such as acme.com, never an email address.
- `userTraits` (object, optional): Optional, requires userId: the user's email or email domain, so Settings can mark your team as known humans by domain (see Your agent report). Only the domain after @ is kept; the address is never stored. Never used for verdicts, authorization or billing; removed when the user is erased. null forgets it.
- `userTraits.email` (string | null, optional) · maxLength 320
- `userTraits.emailDomain` (string | null, optional) · maxLength 253

```json
{
  "sessionToken": "PASTE_BROWSER_SESSION_TOKEN",
  "userId": "usr_123",
  "accountId": "acct_456"
}
```

## Responses

- **200**: Success · body `object`
- **400**: bad_request: the request is invalid; message says what to fix and field names the request field · body `Error`
- **401**: unauthorized (missing or invalid server key, missing Bearer scheme, or a malformed or unsigned session token) or session_reinit_required (a token this collector issued whose session expired, was revoked or was removed); use the code to distinguish · body `Error`
- **403**: forbidden: the server key and session token belong to different Sites, or the call came from a browser page · body `Error`
- **404**: not_found: unknown path, or an ID in the path that is not on this Site · body `Error`
- **409**: conflict: the session already has a different server-verified identity; start a new browser session for the other user · body `Error`
- **413**: payload_too_large: the request body exceeds 131072 bytes · body `Error`
- **429**: rate_limited: wait for Retry-After · body `Error` · headers `Retry-After`
- **500**: internal_error: fail the protected action; do not retry automatically · body `Error`
- **503**: service_unavailable: temporarily unavailable; only verify retries · body `Error`

### Success body fields

- `accepted` (true, required)

Failures are not verdicts. Error bodies follow the `Error` schema: `code` is one of `bad_request`, `unauthorized`, `session_reinit_required`, `forbidden`, `not_found`, `conflict`, `identity_required`, `payload_too_large`, `rate_limited`, `internal_error`, `service_unavailable`, `request_failed`, `approval_invalid`, `approval_used`, `approval_expired`, `approval_mismatch`, `step_up_not_held`, `step_up_mismatch`, `step_up_settled`, `step_up_expired`, `step_up_superseded`, `agent_tokens_disabled`, `tool_not_approved`, `tool_not_read_only`. [Errors and retries](/docs/errors) explains each code and what to do.

## Example

```sh tab=curl
curl -sS -X POST 'https://app.trytelltale.dev/api/v1/identify' \
  -H "Authorization: Bearer $TELLTALE_SERVER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"sessionToken":"PASTE_BROWSER_SESSION_TOKEN","userId":"usr_123","accountId":"acct_456"}'
```

```js tab=fetch
const response = await fetch("https://app.trytelltale.dev/api/v1/identify", {
  method: "POST",
  headers: {
    Authorization: 'Bearer ' + process.env.TELLTALE_SERVER_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    "sessionToken": "PASTE_BROWSER_SESSION_TOKEN",
    "userId": "usr_123",
    "accountId": "acct_456"
  }),
  signal: AbortSignal.timeout(5000)
});
const result = await response.json();
```

```js tab=node
// server.js is the server adapter: download /adapters/v1/server.js (see Server adapters).
import { createTelltaleServer } from './lib/telltale/server.js';

const telltale = createTelltaleServer({ endpoint: "https://app.trytelltale.dev", serverKey: process.env.TELLTALE_SERVER_KEY });
const result = await telltale.identify({
  "sessionToken": "PASTE_BROWSER_SESSION_TOKEN",
  "userId": "usr_123",
  "accountId": "acct_456"
});
```

Machine-readable contract: [openapi.json](https://app.trytelltale.dev/openapi.json). Request builder: the [API explorer](/docs/api-explorer) shows every operation as curl; it sends requests only on a local dashboard.
