# Website identity

Email tells SendOps what happens to your **mail**. [Activities](/api-reference/activities) tell it what happens inside your **product**. Website identity closes the remaining gap: a person you already know comes back to your marketing site, your docs, your pricing page — and a [Drip Workflow](/api-reference/workflows) can act on it.

It is three pieces, and the order matters:

1. **Your server** calls [`POST /v1/contacts/identify`](/api-reference/endpoints/contacts/identify-contact) at login and signup and gets back a **visitor id** for that contact.
2. **Your server** sets that id as a first-party cookie, `so_vid`, on your registrable domain.
3. **The snippet** (`sendops.js`, or the Google Tag Manager template) reads the cookie on any page under that domain and reports one `site_visit` per browser session — plus any `site_*` events you choose to send — to the beacon endpoint.

The beacon resolves the id to the contact and records an activity. An `enter on activity.site_visit` trigger does the rest.


  The identify endpoint is available to every organization today. The beacon endpoint, the snippet and the Workspace → Website page are being enabled per organization; until yours is on, the beacon answers 404 and the Settings page is hidden. Wiring identify and the cookie ahead of time is safe and is the recommended order anyway.


## The design in one paragraph

SendOps never identifies a visitor on its own. The visitor id is random, minted by SendOps, and handed to *your* backend; the snippet only ever **reads** it. A browser without the cookie sends nothing and stores nothing. A cookie carrying an id SendOps does not recognise — a revoked one, a guessed one, a contact archived since — is dropped at the edge with a `204`, no row and no log line naming it. There is no anonymous tracking, no fingerprinting, no cross-site graph: the only person the beacon can ever describe is one who logged into your product.

## Step 1 — mint the visitor id

`POST /v1/contacts/identify` is find-or-create and safe to repeat. Call it from your server, never from a browser: the response is issued to an API key, and the cookie has to arrive as a `Set-Cookie` header from your own domain.

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

```json
{
  "contact_id": "0195f2a0-3c1e-7c4b-9a2d-0e1f2a3b4c5d",
  "visitor_id": "vis_7k3md0q9xtb2rn5vfh8jc1wy4g",
  "result": "created"
}
```

- A contact has **exactly one active visitor id**. The first call mints it (`result: "created"`); every later call returns the same id (`result: "existing"`). Re-calling on every login is the intended usage — it is what keeps the cookie's window rolling.
- **Identity resolution** follows the [activities rules](/api-reference/activities#identity-resolution). An unseen `email` creates the contact. An unseen `external_id` on its own is refused with `422 unknown_external_id` — pair it with `email` to create. An `email` already bound to a *different* external id is `422 external_id_conflict`, permanent until you reconcile.
- A body naming **no identity at all** is `400 validation_failed` — the only `400` on this API.
- Supports the `Idempotency-Key` header: a duplicate within 24 hours replays the original response.
- The first issue for a contact records a **`site_identified`** activity on their timeline. Later calls do not.
- **Archiving the contact revokes the id.** A cookie still in a browser stops resolving; beacons carrying it are dropped.

Requires the `api.contacts.manage` scope. The id is opaque and derived from nothing about the person, but **treat it as a credential and keep it out of your logs** — whoever holds it can attribute visits to that contact.

### Over MCP

An [agent](/api-reference/mcp) can do the same through `contacts_upsert` with `issue_visitor_id: true`; the result carries `visitor_id` and `visitor_id_result`. `contacts_lookup` returns the contact's current `visitor_id` (or `null`). The same rule applies: the value is for the customer's own server to set as a cookie, not for the agent to place anywhere else.

## Step 2 — set the cookie from your server

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

Four things about that line are load-bearing:

| Attribute | Why |
|---|---|
| **Not `HttpOnly`** | The snippet reads the cookie with JavaScript. Mark it `HttpOnly` and the whole feature silently does nothing. |
| **`Domain=.example.com`** — your registrable domain | That is what lets the snippet on `www.`, `docs.` and `blog.example.com` see a cookie your app set on `app.example.com`. Scope it to the app host and only the app can read it — the one place you do not need it. |
| **`Max-Age=34560000`** (400 days) and re-set on every login | `Max-Age` restarts from each `Set-Cookie`, so a returning customer's window never expires. 400 days is the ceiling browsers enforce. |
| **Set by your server, never by JavaScript** | Safari's Intelligent Tracking Prevention caps a script-written cookie at **7 days**. An HTTP `Set-Cookie` from the first party keeps its full lifetime. This is the reason the id is minted server-side at all. |

Clear it 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.


  The cookie identifies a logged-in customer to a service you already use to email them. Whether that needs consent under your jurisdiction and policy is your call, and the snippet has a requireConsent mode for when it does. SendOps does not set the cookie and cannot make that decision for you.


## Step 3 — install the snippet

The site key comes from **Workspace → Website** in the dashboard (`pk_live_…`). It is a publishable key: it identifies your organization to the beacon and can do nothing else — it cannot read, write or send. Rotating it gives the old key a 24-hour grace window.

### Script tag

```html


  sendops.init({ siteKey: "pk_live_…" })

```

That is what the Workspace → Website page hands you, with your key filled in. To load the file asynchronously, install the stub first:

```html

  window.sendops = window.sendops || { q: [] };
  sendops.q.push(['init', { siteKey: 'pk_live_…' }]);
  sendops.q.push(['track', 'site_pricing_viewed', { plan: 'growth' }]);


```

Each queued entry is `[method, ...args]`; the file replays them in order once it loads, so an async tag cannot lose calls. Once loaded, `track` calls made before `init` are buffered (up to 10) and sent when `init` runs.

### Google Tag Manager

Import [`template.tpl`](https://github.com/AltaCoda/sendops-gtm-template/blob/main/template.tpl) from the public [sendops-gtm-template](https://github.com/AltaCoda/sendops-gtm-template) repository as a tag template, create a tag from it with your site key, and fire it on **Initialization – All Pages**. The template reads the `so_vid` cookie itself and skips loading the script entirely for visitors who have never been identified. It declares the `analytics_storage` consent type, so a container with Consent Mode holds it until that is granted.

Custom events go through the data layer — no second tag:

```js
dataLayer.push({ event: 'sendops_track', name: 'site_pricing_viewed', properties: { plan: 'growth' } })
```

### `init` options

| Option | Default | Meaning |
|---|---|---|
| `siteKey` | required | Your `pk_live_…` key. |
| `cookieName` | `so_vid` | Read a differently named cookie. The GTM template's cookie permission must be widened to match. |
| `visitorId` | — | Supply the id directly and skip the cookie read. |
| `requireConsent` | `false` | When `true`, nothing leaves until `sendops.consent(true)` or a Consent Mode grant of `analytics_storage` on `window.dataLayer`. |
| `endpoint` | `https://api.sendops.dev/s/visit` | Override for a non-production stack. |

### What the snippet does — and does not do

- Reads the cookie. **No id, no beacon**: the value must match `^vis_[0-9a-hjkmnp-tv-z]{26}$`, or the snippet does nothing at all — no request, no storage.
- Sends **one `site_visit` per browser session**, marked with a `sessionStorage` key (`so_sid`). A reload is not a new visit.
- `sendops.track(name, properties)` sends a custom event. Names must match `^site_[a-z0-9_]{1,60}$`; anything else is dropped and counted on the health card as a bad name.
- Transport is `navigator.sendBeacon` with a `text/plain` body — no CORS preflight — falling back to `fetch` with `keepalive`.
- It **never writes a cookie, never touches `localStorage`, never generates an identifier**, captures no clicks, and sets no timers. The test suite asserts the first two after every test.

## What the beacon records

Each accepted beacon becomes an activity on the contact's timeline, exactly as if you had sent it to `POST /v1/activities`:

| Field | Value |
|---|---|
| `name` | `site_visit`, or the `site_*` name you passed to `track` |
| `properties` | Up to six standard keys — `path`, `referrer`, `title`, `utm_source`, `utm_medium`, `utm_campaign` — plus, for custom events, up to ten of your own string, number or boolean values. Anything else is dropped. |
| `occurred_at` | Time of receipt |
| Source | `site` — shown on the contact's timeline beside the `api` and `identify` sources |

A session's `site_visit` is idempotent by session id, so a tab restored or a page reloaded costs nothing. Each `track` call carries a fresh event id, so two deliberate calls are two activities.

**Every response is an empty `204`** — accepted, unknown or revoked visitor id, unknown site key, malformed payload, bad event name, over the rate limit. The beacon is a public endpoint and a status that differed by outcome would be an oracle for probing which ids exist. The one exception is a `404` for an organization the feature is not yet enabled for, indistinguishable from the route not existing. Nothing in this path is logged with the visitor id, the site key or the client IP; the health card in the dashboard is where the outcomes are counted.

| Limit | Value |
|---|---|
| Beacons per client IP | 600 per minute |
| `track` events per visitor | 60 per minute |
| Custom properties per event | 10, beyond the six standard keys |

None of these count against your API key's [rate limit](/api-reference/rate-limiting). The beacon needs no key at all.

## Triggering on a visit

Once visits are activities, they are ordinary trigger and predicate material. The canonical "welcome back":

```sendflow
workflow "Welcome back" v2 {
  enter on activity.site_visit
    where exists(activity.site_identified within 90d)
      and not exists(activity.purchase within 90d)
  reentry once

  send "welcome-back"
}
```

And a follow-up on a custom event:

```sendflow
workflow "Pricing follow-up" v2 {
  enter on activity.site_pricing_viewed
    where not exists(activity.purchase within 30d)
  reentry once

  send "pricing-followup"
}
```

`reentry once` is what keeps a busy visitor from being enrolled on every session; see [Workflows](/api-reference/workflows) for the enrollment rules.

## Verifying the install

**Workspace → Website** shows a beacon-health card: daily counts of accepted beacons, unknown visitors, unknown keys, bad names and rate-limited requests over the last seven days, with the time of the last beacon. "Nothing has arrived" after installing is almost always one of four things, all invisible from the browser console:

1. The cookie is `HttpOnly`, so the snippet cannot read it.
2. The cookie's `Domain` is the app host, so the marketing site never sees it.
3. `requireConsent` is on and nothing ever calls `sendops.consent(true)` — or both the GTM consent gate and the checkbox are on, and the beacon waits for a second signal that never comes.
4. The snippet went in before identify was wired, so nobody has a visitor id to send.

A steady stream of **unknown visitor** counts is the expected state for archived contacts and stale cookies; **bad name** counts always mean a bug in your own `track` calls.

## Scopes and permissions

| Surface | Requires |
|---|---|
| `POST /v1/contacts/identify` | API key with `api.contacts.manage` |
| `contacts_upsert` with `issue_visitor_id` | MCP token with `api.contacts.manage` |
| Site key, install guide and health card (dashboard) | `website.view` / `website.manage` — Owner and Org Admin |
| `POST /s/visit`, `GET /sendops.js` | Nothing. Public, keyed by site key. |