Website identity

Search Documentation

Search across all developer documentation

Website identity

Email tells SendOps what happens to your mail. 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 can act on it.

It is three pieces, and the order matters:

  1. Your server calls POST /v1/contacts/identify 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.

Rolling out

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.

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" }'
{
"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. 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 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.

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

Four things about that line are load-bearing:

AttributeWhy
Not HttpOnlyThe snippet reads the cookie with JavaScript. Mark it HttpOnly and the whole feature silently does nothing.
Domain=.example.com — your registrable domainThat 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 loginMax-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 JavaScriptSafari’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.

Consent is yours to manage

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

<script src="https://api.sendops.dev/sendops.js"></script>
<script>
sendops.init({ siteKey: "pk_live_…" })
</script>

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

<script>
window.sendops = window.sendops || { q: [] };
sendops.q.push(['init', { siteKey: 'pk_live_…' }]);
sendops.q.push(['track', 'site_pricing_viewed', { plan: 'growth' }]);
</script>
<script async src="https://api.sendops.dev/sendops.js"></script>

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 from the public 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:

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

init options

OptionDefaultMeaning
siteKeyrequiredYour pk_live_… key.
cookieNameso_vidRead 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.
requireConsentfalseWhen true, nothing leaves until sendops.consent(true) or a Consent Mode grant of analytics_storage on window.dataLayer.
endpointhttps://api.sendops.dev/s/visitOverride 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:

FieldValue
namesite_visit, or the site_* name you passed to track
propertiesUp 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_atTime of receipt
Sourcesite — 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.

LimitValue
Beacons per client IP600 per minute
track events per visitor60 per minute
Custom properties per event10, beyond the six standard keys

None of these count against your API key’s rate limit. 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 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

SurfaceRequires
POST /v1/contacts/identifyAPI key with api.contacts.manage
contacts_upsert with issue_visitor_idMCP 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.jsNothing. Public, keyed by site key.