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:
- Your server calls
POST /v1/contacts/identifyat login and signup and gets back a visitor id for that contact. - Your server sets that id as a first-party cookie,
so_vid, on your registrable domain. - The snippet (
sendops.js, or the Google Tag Manager template) reads the cookie on any page under that domain and reports onesite_visitper browser session — plus anysite_*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
emailcreates the contact. An unseenexternal_idon its own is refused with422 unknown_external_id— pair it withemailto create. Anemailalready bound to a different external id is422 external_id_conflict, permanent until you reconcile. - A body naming no identity at all is
400 validation_failed— the only400on this API. - Supports the
Idempotency-Keyheader: a duplicate within 24 hours replays the original response. - The first issue for a contact records a
site_identifiedactivity 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.
Step 2 — set the cookie from your server
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.
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
| 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_visitper browser session, marked with asessionStoragekey (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.sendBeaconwith atext/plainbody — no CORS preflight — falling back tofetchwithkeepalive. - 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. 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”:
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:
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:
- The cookie is
HttpOnly, so the snippet cannot read it. - The cookie’s
Domainis the app host, so the marketing site never sees it. requireConsentis on and nothing ever callssendops.consent(true)— or both the GTM consent gate and the checkbox are on, and the beacon waits for a second signal that never comes.- 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. |