---
title: "Visitor analytics"
description: "Cookieless visitor counts for your blog: the script tag and its attributes, when it stays silent, the domains that count, verifying an install, and the analytics API."
canonical: "https://writavo.com/docs/analytics"
---

# Visitor analytics

Visitors, pages, sources, countries and devices for your blog, counted by Writavo itself. No cookies and nothing stored on the reader's device. A blog Writavo hosts is already counted; a site you render yourself needs one script tag, and everything about it is on this page.

## What it counts, and what it keeps

Analytics is part of the CMS, free and the same on every plan. It is on for every Site from the start, and it counts pageviews, unique visitors, bounces and time on page, broken down by page, entry page, source, referrer, UTM tags, country, region, city, device, browser and operating system.

- **No cookie, no localStorage id, no fingerprint.** A visitor is a hash of the day's random salt, the Site, the IP address and the browser's user agent. The salt is replaced every UTC day and deleted the day after, so a hash can never be turned back into an address, and the same person counts as a new visitor tomorrow. Unique visitors over a range are the sum of each day's uniques.
- **Never stored:** the IP address, the full user agent, the query string, the full referrer URL, and anything that identifies a person.
- **Stored per event:** the hash, the page's host and path, the referrer's host, `utm_source` (or `ref` or `source`), `utm_medium` and `utm_campaign` from the page address, the country, region and city from our edge's location headers, the browser, the operating system, a device class (desktop, mobile or tablet), and for an engagement event the time the tab was visible.
- **Kept:** raw events for 90 days, daily totals for as long as the Site exists.
- **Dropped before counting:** known bots and crawlers, link prefetch and prerender requests, and more than 300 events a minute from one address for one Site.

Because it sets no cookies and stores nothing on the reader's device, it needs no consent banner of its own.

## Hosted blogs need nothing

A blog Writavo serves (your writavo.com address, a custom domain, or a reverse proxy such as example.com/blog) already carries the tag on every page. There is nothing to install. Open Visitors in the dashboard to see the numbers. The rest of this page is for a site you render yourself from the API.

## Add the script to your own site

Paste one tag into the <head> of every page that shows your articles. It must be in the HTML the server sends, not added later by your own code (see Next.js and React below).

html:

```
<script defer src="https://blog.writavo.com/api/a/s" data-site="wa_0123456789abcdef01234567"></script>
```

- **The address is always `https://blog.writavo.com/api/a/s`,** the public collector. Never use a `localhost` or `127.0.0.1` address you saw while developing: a reader's browser cannot reach it, and nothing is counted.
- **Your site id** is `wa_` followed by 24 hexadecimal characters. It is public by design and is not a key. Find it in the dashboard under Visitors, Setup (the snippet there is ready to copy), or read `site_id` and `snippet` from `GET /v1/analytics/setup`.
- **The page's domain must be one the Site counts** (see Domains that count). A tag on any other domain is turned away.

## Attributes

| Attribute | Required | What it does |
|---|---|---|
| `data-site` | yes | Your site id, `wa_` plus 24 hexadecimal characters. Without it the script does nothing. |
| `data-include` | no | A regular expression tested against the page's path only (no domain, no query string). Only matching pages are counted. Without it, every page the script sees counts, including pages a reader moves on to in a single-page app. An invalid expression is ignored, so every page counts. |
| `data-api` | no | The full URL beacons are sent to. Without it they go to the origin the script was loaded from plus `/api/a/e`, which for the standard tag is `https://blog.writavo.com/api/a/e`. |
| `defer` | recommended | Loads the script without holding up the page. |

data-include examples:

```
<!-- only /blog and everything under it -->
<script defer src="https://blog.writavo.com/api/a/s" data-site="wa_0123456789abcdef01234567"
  data-include="^/blog(/|$)"></script>

<!-- the blog under an optional two-letter locale: /blog/..., /en/blog/..., /de/blog/... -->
<script defer src="https://blog.writavo.com/api/a/s" data-site="wa_0123456789abcdef01234567"
  data-include="^/([a-z]{2}/)?blog(/|$)"></script>
```

The expression is case sensitive and is not anchored for you: start it with ^ when you mean the start of the path. When a reader moves from a counted page to an excluded one, the time spent on the counted page is still sent; the excluded page is not counted.

> Leave data-api unset unless you must. When you send beacons through your own server, the collector sees your server's address rather than the reader's, so readers can merge into one visitor, country and city become your server's, and all your traffic shares the limit of 300 events a minute per address. If you proxy it anyway, point data-api at the full URL your server forwards to https://blog.writavo.com/api/a/e, send the body unchanged as text/plain, pass the reader's User-Agent through, and allow the proxy's host in connect-src.

## What the script does

- **One pageview when the page loads**, and one for every client-side navigation: the script wraps `history.pushState` and listens for `popstate` (back and forward), so Next.js, React Router, Vue Router and similar apps count each page change. `history.replaceState` is not counted, a change to the `#fragment` alone is not counted, and moving to the address you are already on is not counted twice.
- **One engagement event when the reader leaves a page** (a navigation, closing the tab, or switching away so the tab is hidden), carrying the time the tab was actually visible. Under one second is not sent. A tab hidden and shown again sends one event per visible stretch.
- **The referrer** is `document.referrer` for the first page and the previous page's address for each client-side navigation. A referral from the same site (with or without `www.`) is not counted as a source.
- **Loading the script twice** (a layout that mounts again) does nothing the second time. A prerendered page waits until it is actually shown.
- **It sends with `navigator.sendBeacon`** as `text/plain` (falling back to `fetch` with `keepalive`, no credentials), so there is no CORS preflight and no cookie is sent.
- **The collector answers `202` with an empty body to every beacon,** whether it was counted or not. That is deliberate: a different answer for an unknown site id would let anyone find out which ids exist.

## When the script stays silent

The script sends nothing at all when any of these is true:

- **The page is local:** the host is exactly `localhost`, starts with `127.`, is `0.0.0.0` or `[::1]`, or the page was opened from a `file:` address. Other private addresses, such as `192.168.x.x` or `myapp.test`, are not treated as local; their beacons reach the collector and are turned away because the domain is not one the Site counts.
- **The browser is automated:** `navigator.webdriver` is true (Selenium, Puppeteer, Playwright and most headless browsers set it), or the page runs under Cypress (`window.Cypress`), PhantomJS (`window._phantom`) or Nightmare (`window.__nightmare`).
- **The reader opted out:** `localStorage.getItem("writavo_ignore")` is exactly the string `"true"`.
- **The tag has no `data-site`,** or the script could not tell which tag loaded it (it relies on `document.currentScript`, so load it as a classic script, never `type="module"`).

This is why an automated test or a page checker never shows up in your numbers, and why a check run from a headless browser cannot prove an install: use the install check below, then visit the page yourself in an ordinary browser.

## Leave your own visits out

Open your site in the browser you use, open the developer console, and run:

browser console:

```
localStorage.writavo_ignore = "true"
```

It is stored per browser and per domain, so repeat it on each domain your blog is shown on (example.com and blog.example.com are two). To be counted again, run localStorage.removeItem("writavo_ignore"). Any value other than the exact string "true" counts you.

## Content-Security-Policy

If your site sends a Content-Security-Policy header, allow the collector in both directives: script-src to load the script and connect-src to send the beacons.

header:

```
Content-Security-Policy: script-src 'self' https://blog.writavo.com; connect-src 'self' https://blog.writavo.com
```

Keep whatever else your policy already allows; these are the two additions. If you set data-api, connect-src must allow that URL's origin instead.

## Domains that count

A pageview is only counted when the page is on a domain that belongs to the Site, so nobody can push made-up traffic into your numbers from somewhere else. The Site counts:

- **its primary domain and every subdomain of it** (`source: primary_domain`),
- **its custom domains** that have not failed verification, exactly (`source: custom_hostname`),
- **the domain of a reverse proxy** it is connected through, exactly, plus its `www.` form (`source: proxy`),
- **up to 20 extra domains** you add, each with its subdomains (`source: extra_domain`). Add the domain your own front end runs on when it is not the primary domain: in the dashboard under Visitors, Setup, or with `PATCH /v1/analytics/settings`.

A beacon from any other host is dropped, and the host is listed for you as turned away: under Visitors, Setup in the dashboard, and in install.rejected_hosts from GET /v1/analytics/setup (the last two days, up to 20 hosts, with a hit count). A host you expected to count in that list is the usual answer to "the tag is there and nothing arrives": add it as an extra domain. A staging or preview address there is harmless: leave it out unless you want its visits counted.

> A Site whose analytics is switched off, or whose organisation is not active, counts nothing. Turning analytics off keeps the numbers already collected.

## Verify the install

Do not test the collector by sending it a request and reading the status: it answers 202 to everything, counted or not, so a 202 proves nothing. Use one of these instead.

1. **Run the install check.** In the dashboard, Visitors, Setup, Check installation; or `POST /v1/analytics/install-check` with the page's `url`. It fetches the page as a server would, finds the tag, and checks that the site id is this Site's, that the script and collector are the public ones, that `data-include` matches the page, that collecting is on, and whether events have arrived recently.
2. **Visit the page in an ordinary browser.** Not a headless or automated one, and not one where you set `writavo_ignore`: the script is silent there by design. One real visit is enough.
3. **Read what arrived.** `GET /v1/analytics/setup` returns `install.last_event_at` with `last_event_host` and `last_event_path`, and `install.events_24h`. These come from the raw events, so they move within seconds. An event is a pageview or one of the time-on-page pings the script sends while a reader stays, so `events_24h` is normally higher than the pageview count. In the visitor report, `entry_page` rows count whole visits by the page they started on (every page viewed after entering there), not views of that page; for views of a page, read `page`. The visitor totals are rolled up every 15 minutes, so a new visit can take that long to show in `GET /v1/analytics/visitors` and on the Visitors page.
4. **If nothing arrived, read `install.rejected_hosts`.** A host there means beacons reached the collector from a domain the Site does not count: add it to `extra_domains`. An empty list and no events means no beacon left the browser: check the tag is in the server HTML, the Content-Security-Policy, an ad blocker, and that you are not on `localhost`.

## Next.js and React

Render the plain tag in the root layout's head, so it is in the server-rendered HTML of every page. The script counts client-side navigations itself; do not add it per page.

app/layout.tsx (Next.js App Router):

```
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <script
          defer
          src="https://blog.writavo.com/api/a/s"
          data-site="wa_0123456789abcdef01234567"
          data-include="^/blog(/|$)"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}
```

- **Pages Router:** put the same tag inside `<Head>` in `pages/_document.tsx`.
- **A single-page app (Vite, Create React App):** put the tag in the `<head>` of `index.html`.
- **`next/script` with `strategy="afterInteractive"` also counts,** because it passes the `data-` attributes through. But it adds the tag in the browser after hydration, so the install check, which reads the server HTML, reports the tag as missing. Confirm such an install with `install.last_event_at` instead, or use the plain tag.
- **Do not** load it with `type="module"` or bundle its source into your own code: it finds its own tag through `document.currentScript`.

## The analytics API

Four operations, on the base URL https://api.writavo.com/v1, with a secret key. They are part of the CMS: free, and the same on every plan.

| Request | Scope | What it does |
|---|---|---|
| `GET /analytics/setup` | `insights:read` | The site id, the ready snippet, the domains that count, and what has arrived |
| `PATCH /analytics/settings` | `site:write` | Turn collecting on or off, and set the extra domains |
| `GET /analytics/visitors` | `insights:read` | Totals, the previous period, the daily series and every breakdown |
| `POST /analytics/install-check` | `insights:read` | Fetch a page, find the tag, and say what is wrong |

## GET /analytics/setup

curl:

```bash
curl -s https://api.writavo.com/v1/analytics/setup \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY"
```

200 response:

```json
{
  "ok": true,
  "data": {
    "site_id": "wa_0123456789abcdef01234567",
    "enabled": true,
    "collector_origin": "https://blog.writavo.com",
    "script_url": "https://blog.writavo.com/api/a/s",
    "snippet": "<script defer src=\"https://blog.writavo.com/api/a/s\" data-site=\"wa_0123456789abcdef01234567\"></script>",
    "extra_domains": [],
    "allowed_domains": [
      { "domain": "example.com", "source": "primary_domain", "subdomains": true },
      { "domain": "blog.example.com", "source": "custom_hostname", "subdomains": false }
    ],
    "install": {
      "last_event_at": "2026-10-04T09:12:44Z",
      "last_event_host": "example.com",
      "last_event_path": "/blog/hello-world",
      "events_24h": 12,
      "rejected_hosts": [
        { "hostname": "staging.example.net", "hits": 3, "last_seen_at": "2026-10-04T08:55:02Z" }
      ]
    },
    "docs_url": "https://writavo.com/docs/analytics"
  }
}
```

- `snippet` is the exact tag to paste. `last_event_at`, `last_event_host` and `last_event_path` are `null` until the first event (they look back 90 days).
- `allowed_domains[].source` is `primary_domain`, `custom_hostname`, `proxy` or `extra_domain`; `subdomains` says whether its subdomains count too.
- `rejected_hosts` covers today and yesterday (UTC), newest first, at most 20.

## PATCH /analytics/settings

curl:

```bash
curl -s -X PATCH https://api.writavo.com/v1/analytics/settings \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "extra_domains": ["example.org", "app.example.net"] }'
```

- Send `enabled` (true or false), `extra_domains` (an array of up to 20 domains), or both; an empty body or any other field is `422 VALIDATION_FAILED`. A field you leave out is unchanged; `extra_domains` replaces the whole list, so send every domain you want to keep.
- A pasted address is accepted: the scheme, the path, the port and any trailing dot are removed, and the domain is lower-cased. A value that is not a domain, or more than 20, is `422 VALIDATION_FAILED` with the field named.
- It answers with the same body as `GET /analytics/setup`.

## GET /analytics/visitors

curl:

```bash
curl -s "https://api.writavo.com/v1/analytics/visitors?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY"
```

200 response (shortened):

```json
{
  "ok": true,
  "data": {
    "from": "2026-09-01",
    "to": "2026-09-30",
    "totals": {
      "visitors": 4120, "pageviews": 6935, "bounces": 2810,
      "engaged_ms": 391204000, "engaged_samples": 5230, "avg_engaged_seconds": 75
    },
    "previous": {
      "visitors": 3644, "pageviews": 6012, "bounces": 2501,
      "engaged_ms": 340110000, "engaged_samples": 4602, "avg_engaged_seconds": 74
    },
    "daily": [
      { "day": "2026-09-01", "visitors": 131, "pageviews": 220, "bounces": 90,
        "engaged_ms": 12001000, "engaged_samples": 160 }
    ],
    "breakdowns": {
      "page": [
        { "value": "/blog/hello-world", "visitors": 812, "pageviews": 1010, "bounces": 640,
          "engaged_ms": 80100000, "engaged_samples": 900 }
      ],
      "source": [
        { "value": "Google", "visitors": 2210, "pageviews": 3400, "bounces": 1500,
          "engaged_ms": 190000000, "engaged_samples": 2600 }
      ],
      "entry_page": [], "referrer": [], "utm_source": [], "utm_medium": [], "utm_campaign": [],
      "country": [], "region": [], "city": [], "device": [], "browser": [], "os": []
    }
  }
}
```

- `from` and `to` are UTC dates, inclusive. Both default to the last 30 days (today and the 29 before it); `from` must be on or before `to`, at most 400 days apart, or it is `422 VALIDATION_FAILED`.
- `previous` is the same number of days immediately before `from`, for comparisons.
- `daily` has one row per day in the range, zeros included. Totals are rolled up every 15 minutes, and yesterday is finalised just after midnight UTC.
- All 13 breakdowns are always present (empty when there is no data), each at most 50 rows, most visitors first. `source` is a named source (Google, ChatGPT, Reddit, Gmail) or the referring host; `referrer` is the referring host; `country` is an ISO 3166 two-letter code; `device` is `desktop`, `mobile` or `tablet`.
- A bounce is a visitor with exactly one pageview that day. `avg_engaged_seconds` is `engaged_ms / engaged_samples` in whole seconds (0 with no samples).
- A date that is not `YYYY-MM-DD` is `422 VALIDATION_FAILED` naming the field.

## POST /analytics/install-check

curl:

```bash
curl -s -X POST https://api.writavo.com/v1/analytics/install-check \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/blog/hello-world" }'
```

200 response:

```json
{
  "ok": true,
  "data": {
    "url": "https://example.com/blog/hello-world",
    "final_url": "https://example.com/blog/hello-world",
    "status": "ok",
    "checks": [
      { "key": "page_reachable", "ok": true, "message": "The page answered HTTP 200 with HTML." },
      { "key": "tag_present", "ok": true, "message": "Found the analytics script tag in the page's HTML." },
      { "key": "site_id_matches", "ok": true, "message": "data-site is this Site's id (wa_0123456789abcdef01234567)." },
      { "key": "collector_public", "ok": true, "message": "The script loads from the public collector (https://blog.writavo.com)." },
      { "key": "include_matches", "ok": true, "message": "data-include matches this page's path (/blog/hello-world), so the page is counted." },
      { "key": "collecting_enabled", "ok": true, "message": "Collecting is on for this Site." },
      { "key": "recent_events", "ok": true, "message": "12 events arrived in the last 24 hours. The last arrived at 2026-10-04T09:12:44Z from example.com/blog/hello-world." }
    ],
    "script": {
      "found": true,
      "src": "https://blog.writavo.com/api/a/s",
      "site_id": "wa_0123456789abcdef01234567",
      "data_include": "^/blog(/|$)",
      "data_api": null
    },
    "install": {
      "last_event_at": "2026-10-04T09:12:44Z",
      "last_event_host": "example.com",
      "last_event_path": "/blog/hello-world",
      "events_24h": 12,
      "rejected_hosts": []
    }
  }
}
```

- `url` is optional: a full `http` or `https` address on a domain the Site counts. Another domain is `422 VALIDATION_FAILED` naming `url`, before anything is fetched. Without it, the Site's own address is checked (its first verified custom domain, else its primary domain).
- The check fetches that one page (10 second timeout) and reads the HTML the server sends. It does not run scripts, which is why a tag added in the browser after load is not seen. `final_url` is where the page ended up after redirects (`null` if it could not be read); a redirect to a domain the Site does not count fails `collecting_enabled`.
- `status` is `failed` when the page cannot be counted: one of `page_reachable`, `tag_present`, `site_id_matches` or `collecting_enabled` failed. It is `warning` when only `collector_public`, `include_matches` or `recent_events` failed, and `ok` when every check passed. Every failed check's `message` says what to change.
- `script` is the tag that was read (`null` when there is none). With several tags on the page, the first one runs and the check says to remove the others.
- Branch on each check's `key` and `ok`. `message` is written for a person: show it, do not parse it.
- A real visit is still needed for `recent_events` to pass, since the check itself is a server fetch and is never counted.

## From an AI assistant (MCP)

Every operation above is an action on the Writavo MCP server. Call search_writavo_actions with a few words, for example "analytics setup", "visitor numbers" or "analytics install check"; it returns the operation id and its inputs. Run reads (setup, visitors) with read_writavo_action and changes (settings, the install check) with run_writavo_action, passing the operation id and the arguments. The connection needs the Reports and logs permission to read, and Site settings to change the domains.

## Agent checklist

Asked to "add Writavo analytics to my site", do exactly this, in order:

1. **Read the setup.** `GET /v1/analytics/setup` (MCP: search "analytics setup", then `read_writavo_action`). If `enabled` is false, ask the person before turning it on with `PATCH /v1/analytics/settings` and `{ "enabled": true }`.
2. **Decide whether anything needs installing.** Pages Writavo serves (the writavo.com address, a custom domain listed with `source: custom_hostname`, or a reverse proxy listed with `source: proxy`) already carry the tag: go straight to step 5. Only a site the person renders themselves from the API needs the tag.
3. **Make sure the page's domain counts.** If the domain the front end runs on is not covered by `allowed_domains`, add it with `PATCH /v1/analytics/settings`, sending the current `extra_domains` plus the new one (the list is replaced, not merged).
4. **Paste the returned `snippet` unchanged and deploy.** Put it in the server-rendered `<head>` of the root layout, once. Add `data-include` only when the blog is part of a bigger app, and test the expression against a real blog path first. Never change `src` to a local address. If the site sends a Content-Security-Policy, add `https://blog.writavo.com` to `script-src` and `connect-src`.
5. **Run the install check.** `POST /v1/analytics/install-check` with the URL of a live blog page. Fix every check that is not `ok` and run it again until `status` is `ok`, or `warning` with only `recent_events` failing.
6. **Get one real visit.** Ask the person to open the page once in their ordinary browser (your automated browser is never counted). Then read `GET /v1/analytics/setup` again: `install.last_event_at` should be recent and `last_event_host` their domain. If it is still null, read `install.rejected_hosts` and add any host of theirs as an extra domain.
7. **Report back.** Say which domain is counted and when the last event arrived, and that totals appear on the Visitors page within 15 minutes.
