Everything on this page is also on the **Workspace → Website** page in the dashboard, with your own site key filled into the samples. Do the steps in this order: identify first, cookie second, snippet last. The snippet only reads; installed early, it has nothing to send.

## The site key

Open **Workspace → Website** and click **Create site key**. The key starts with `pk_live_` and is **publishable** — it goes into your page source, it identifies your workspace to the beacon, and it cannot read, write or send anything. Someone who finds it in your HTML gains nothing.



- **Reveal** and **Copy** show and copy the key.
- **Rotate** issues a new key and keeps the old one working for **24 hours**, so a site you cannot redeploy instantly does not go dark. The card shows when the previous key stops working.
- **Revoke** retires the key without issuing a replacement. It too keeps working for 24 hours, then your site stops reporting visits until you create a new key. Use it if the key has been misused; to replace it, rotate instead.


  <Step title="Call identify from your backend">
    At login and at signup, your server calls `POST /v1/contacts/identify` with the customer's `email`, your own `external_id`, or both. It returns the contact's **visitor id**, a value like `vis_7k3md0q9xtb2rn5vfh8jc1wy4g`.

    The call is safe to repeat: the first one mints the id, every later one returns the same id. Calling it on every login is the intended pattern — it is what keeps the cookie fresh. It needs an [API key](/api-integrations/api-keys) with the `api.contacts.manage` scope, and it must be your server making the call, never the browser.

    The dashboard shows a Node and a Go sample; the [developer guide](https://developers.sendops.dev/api-reference/website-identity) has the full request and response.
  </Step>

  <Step title="Set the visitor id as a first-party cookie">
    Your server puts the id in a cookie named `so_vid` on the response to that login:

    ```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, and each one, done wrong, makes the feature silently do nothing:

    - **Not `HttpOnly`.** The snippet reads the cookie with JavaScript. `HttpOnly` hides it.
    - **`Domain` is your registrable domain** — `.example.com`, not `app.example.com`. That is what lets the snippet on `www.`, `docs.` and `blog.example.com` see a cookie your app set. Scope it to the app host and only the app can read it, which is the one place you do not need it.
    - **Re-set it on every login.** `Max-Age` restarts from each `Set-Cookie`, so a returning customer's window never expires. 400 days is the ceiling browsers allow.
    - **Set it from your server, never from JavaScript.** Safari's tracking prevention caps a script-written cookie at seven days. An HTTP cookie from your own domain keeps its full lifetime.

    **Clear it on logout** — `Max-Age=0`, same `Domain` and `Path` — so a shared computer does not attribute the next person's visits to the one who left.
  </Step>

  <Step title="Install the snippet">
    Either the script tag or the Google Tag Manager template, below. Both need only the site key.
  </Step>


## Script tag

Put this on every page of every site under your domain — marketing site, docs, blog. No backend is needed on those sites; the cookie your app set is already there.

```html


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

```

The file is about 2 KB, has no dependencies, and does nothing at all on a browser without the cookie.

## Google Tag Manager

The template is the better route if you already run Tag Manager: it is sandboxed, its permissions are visible, and it skips loading the script entirely for visitors who have never been identified.


  <Step title="Import the template">
    Download [`template.tpl`](https://github.com/AltaCoda/sendops-gtm-template/blob/main/template.tpl) from the [SendOps GTM template repository](https://github.com/AltaCoda/sendops-gtm-template). In Tag Manager go to **Templates → Tag Templates → New**, open the ⋮ menu, choose **Import**, and pick the file.
  </Step>
  <Step title="Create the tag">
    **Tags → New**, choose **SendOps Website Beacon**, and paste your **Site key**.
  </Step>
  <Step title="Trigger it on every page">
    Set the trigger to **Initialization – All Pages** and publish the container.
  </Step>


If you renamed the cookie, the template's permission has to match: the imported template allows reading `so_vid` only. Open the template → **Permissions** → *Reads cookie values* and add your name, or the tag fails with a permission error.

If your container uses **Consent Mode**, the template already declares the `analytics_storage` type and Tag Manager holds it until that is granted. Leave the template's *Wait for in-page consent* checkbox **off** in that setup — turning both on means the beacon waits for a second signal that never arrives.

## Consent

By default the cookie's presence is the go-ahead: SendOps only ever hears about people who logged into your product, and whether that needs a consent step under your policy is your decision.

When it does, initialise with `requireConsent: true`:

```html

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

```

Nothing is sent until one of two things happens: your consent banner calls `sendops.consent(true)`, or Google Consent Mode grants `analytics_storage` on the data layer. A denial drops whatever was waiting. `sendops.consent(false)` is obeyed in either mode.

## Custom events

Beyond the visit itself, record your own page events:

```js
sendops.track("site_pricing_viewed", { plan: "growth" })
```

With Tag Manager, push to the data layer instead — no second tag needed:

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

- Names must start with `site_` and use lowercase letters, digits and underscores, up to 60 characters after the prefix. Anything else is dropped and counted as a **bad name** on the health card.
- Properties can carry up to ten of your own string, number or boolean values. Every event also carries the page path, referrer, title and `utm_source` / `utm_medium` / `utm_campaign` when present.
- Each `track` call is its own activity. `site_visit` itself is sent once per browser session; a reload is not a new visit.

Custom events are plain activities, so `enter on activity.site_pricing_viewed` works in a workflow and `exists(activity.site_pricing_viewed within 7d)` works in a segment. See [Activities](/audience/activities) for what you can do with them from there.


  It is tempting to read the id from your app and write the cookie with a line of script. On Safari that cookie will expire in seven days, and your returning customers will quietly stop being recognised. The `Set-Cookie` header from your server is the only version that lasts.