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-tab session on each host — 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 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. 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.

One template makes two kinds of tag, chosen with its Tag type select:

Tag typeWhat it doesFire it on
Page visit (the default)Loads sendops.js and calls init with your Site key. Sends the session’s site_visit.Initialization – All Pages
Track eventCalls sendops.track with an Event name and an optional Event properties table.Any trigger you like

A Track event tag never calls init, so a Page visit tag must be installed as well — it is the one that starts the beacon. Tag order does not matter: a track call that lands before init waits in the snippet’s pre-init queue (up to 10 calls) and is sent when init runs. Tags saved before Track event existed carry no tag type and keep behaving as Page visit.

  • Event name must match ^site_[a-z0-9_]{1,60}$. The template validates it when you save the tag.
  • Event properties is a name/value table. Names must match ^[a-z][a-z0-9_]{0,31}$; the tag drops any other row before sending. GTM variables work in the value column, and every value is sent as a string. A row whose variable is unset is left out rather than sent as "undefined".

The common case needs no page code at all. To record a pricing-page view:

  1. Triggers → New, type Page View, firing on Some Page Views where Page Path contains /pricing.
  2. Tags → New, SendOps Website Beacon, Tag type = Track event, Event name = site_pricing_viewed. Attach the trigger and publish.
  3. In SendOps, enter on activity.site_pricing_viewed now works in a workflow.

Events raised by your own page code can go through the data layer instead — sendops.js watches it, so no tag is needed:

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-tab session, per host, marked with a sessionStorage key (so_sid). A reload or a navigation within the tab is not a new visit. sessionStorage is scoped to the origin, so www.example.com and docs.example.com each send their own site_visit in the same tab, and a new tab is a new session.
  • 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. Every custom event carries the same six page properties as site_visit, read from the page it fired on, alongside your own.
  • 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.

The page properties come from the browser: path is the URL path plus its query string (/pricing?utm_source=news), referrer is document.referrer, title is document.title, and each utm_* key is present only when the URL carries it. Values longer than 512 bytes are truncated. Your own property names must match ^[a-z][a-z0-9_]{0,31}$; past ten, which extra keys survive is arbitrary, so send only the ones you need.

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

Filtering on page properties

Every activity whose name starts with site_ carries path, referrer, title, utm_source, utm_medium and utm_campaign as built-in string properties. They are queryable in a SendQL predicate on every organization with no promotion:

SendQL
exists(activity.site_visit where path starts with "/pricing")
count(activity.site_visit where utm_campaign = "spring-launch" within 30d) >= 2

Because path includes the query string, prefer starts with or contains to = — a UTM-tagged link makes path = "/pricing" miss.

  • Promoting a built-in returns 409 conflict, from POST /v1/activity-properties and from its preview alike. There is nothing to promote, and a second definition could disagree about the type.
  • Your own properties still need promoting. A reason or plan sent with a site_* event is inert JSON until you promote it — through POST /v1/activity-properties, or Audience → Activity properties in the dashboard — exactly like any other activity property. Values sent by the GTM template are always strings, so promote those as string or enum.
  • Agents see the same split. MCP’s audience_get_schema lists promoted properties under activity_properties and the built-ins under builtin_activity_properties (activity_prefix: "site_" plus the six names and types).

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.

Below the counts, Events received, last 7 days lists each site_* name recorded in that window with its count and when it was last seen — site_visit first, then the busiest. Counts alone cannot tell a Track event tag that fired from one that never ran; this list can. A new tag’s event name appears here once it has fired for an identified visitor.

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.