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-tab session on each host — 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.
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. 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 type | What it does | Fire 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 event | Calls 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:
- Triggers → New, type Page View, firing on Some Page Views where Page Path contains
/pricing. - Tags → New, SendOps Website Beacon, Tag type = Track event, Event name =
site_pricing_viewed. Attach the trigger and publish. - In SendOps,
enter on activity.site_pricing_viewednow 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
| 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-tab session, per host, marked with asessionStoragekey (so_sid). A reload or a navigation within the tab is not a new visit.sessionStorageis scoped to the origin, sowww.example.comanddocs.example.comeach send their ownsite_visitin 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 assite_visit, read from the page it fired on, alongside your own.- 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.
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.
| 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.
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:
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, fromPOST /v1/activity-propertiesand 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
reasonorplansent with asite_*event is inert JSON until you promote it — throughPOST /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 asstringorenum. - Agents see the same split. MCP’s
audience_get_schemalists promoted properties underactivity_propertiesand the built-ins underbuiltin_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”:
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.
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
| 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. |