# Issue a contact's browser visitor id

`POST /v1/contacts/identify`

- Authentication: required (Bearer token)
- Required scope: `api.contacts.manage`

Returns the contact's **visitor id** — a random, opaque identifier you
set as a first-party cookie so that SendOps can recognise the person
when they visit your website.

Call this from **your own server**, at login and at signup. Never from
a browser: the response is issued to an API key, and the cookie has to
be set by a `Set-Cookie` header from your own domain.

Find-or-create and safe to repeat. A contact has exactly one active
visitor id; the first call mints it and returns `result: "created"`,
every later call returns the same id with `result: "existing"`.
Re-calling on every login is the intended usage — it is what keeps the
cookie's 400-day window rolling.

### Setting the cookie

```http
Set-Cookie: so_vid=vis_…; Max-Age=34560000; Domain=.example.com; Path=/; Secure; SameSite=Lax
```

Four things about that line are load-bearing:

- **Not `HttpOnly`.** The SendOps snippet reads the cookie with
  JavaScript. Marking it `HttpOnly` makes the whole feature silently
  do nothing.
- **`Domain` is your registrable domain** (`.example.com`), not the
  host your app runs on. That is what lets the snippet on
  `www.example.com`, `docs.example.com` and `blog.example.com` see it.
  Scoping it to `app.example.com` means only your app can read it,
  which is the one place you do not need it.
- **Re-set it on every login.** `Max-Age` restarts from each
  `Set-Cookie`, so a returning customer's window never expires.
- **Never write it from JavaScript.** Safari's Intelligent Tracking
  Prevention caps a script-written cookie at 7 days, which defeats the
  point. Set it from your server, and let the snippet only read it.

Clear the cookie on logout (`Max-Age=0`, same `Domain` and `Path`), so a
shared browser does not attribute the next person's visits to the one
who left.

### Identity resolution

Supply `email`, `external_id`, or both. The rules match
`POST /v1/activities`:

- An `email` we have not seen creates a contact for it.
- An `external_id` we have not seen, with no `email` alongside it, is
  refused with `422 unknown_external_id` — an external id is your own
  key, and one we have never seen is more likely a typo than a person.
- An `email` whose contact already carries a *different* `external_id`
  is refused with `422 external_id_conflict`.

Archiving a contact revokes its visitor id: any cookie still in a
browser stops resolving, and the beacon carrying it is dropped without
being recorded.

Supports the `Idempotency-Key` header: a duplicate key within 24 h
replays the original response.

## Request body

Content type: `application/json`

```json
{
  "email": "user@example.com",
  "external_id": "string"
}
```

## Example request

```bash
curl -X POST 'https://api.sendops.dev/v1/contacts/identify' \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","external_id":"string"}'
```

## Responses

### 200 — The contact's active visitor id

Content type: `application/json`

```json
{
  "contact_id": "00000000-0000-0000-0000-000000000000",
  "visitor_id": "vis_7k3md0q9xtb2rn5vfh8jc1wy4g",
  "result": "created"
}
```

### 400 — The body named no identity at all — neither `email` nor
`external_id`. The `code` is `validation_failed`.

Declared here rather than as a shared response because it is the
only `400` on this API: everywhere else a validation fault is
`422`, and this one is the narrower "there is nothing here to
process" case.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 401 — Missing, malformed, or unknown API key

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 403 — Key lacks the required scope or plan limit violated

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 404 — Resource not found

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 422 — The identity could not be resolved. Nothing was written.

- `unknown_external_id` — no contact carries that `external_id` and
  no `email` was supplied. Supply an email alongside it, or create
  the contact first.
- `external_id_conflict` — that `email` belongs to a contact under
  a different `external_id`. Permanent until reconciled; retrying
  changes nothing.
- `validation_failed` — the body was not a JSON object.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 429 — Per-org rate limit exceeded

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 500 — Unexpected server-side failure. The `code` is `internal_error`. The
`request_id` field can be quoted to SendOps support to investigate.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```
