---
title: "Analytics"
description: "Visitor analytics for the pages that show your articles, wherever they are served: the setup (site id and snippet), turning it on or off, the domains that count, the visitor report, and a check that the script is installed on a page. Cookieless: a visitor is counted once a day from a hash under a salt that changes daily, and no cookie or browser storage id is set. Hosted Writavo blogs carry the script automatically; anywhere else, paste the `snippet` from `GET /analytics/setup` inside `<head>`. Guide: https://writavo.com/docs/analytics.  The script tag's attributes:  - `data-site` (required): the Site's public id, `wa_` followed by 24 hex characters. - `data-include` (optional): a regular expression; only pages whose path matches it are counted,   for a blog inside a bigger app (`data-include=\"^/blog(/|$)\"`). An invalid pattern is ignored   and every page counts. - `data-api` (optional): the full URL to send events to instead of the collector's own   `/api/a/e`, for a setup that forwards them itself.  It counts one pageview on load and on every client-side navigation, and the time the page was visible when the reader leaves. It stays silent on localhost, 127.x, 0.0.0.0 and file: pages, in automated browsers (`navigator.webdriver`, Cypress, PhantomJS, Nightmare), and for a visitor who sets `localStorage.writavo_ignore = \"true\"` (to exclude your own visits). A Content Security Policy must allow the collector origin in both `script-src` and `connect-src`. The collector answers 202 to every event, accepted or not, so it cannot tell you whether an install works: use `POST /analytics/install-check`, or `install` in `GET /analytics/setup`, which shows the last event that arrived and any hosts that were turned away."
canonical: "https://writavo.com/docs/api/analytics"
last-updated: "2026-09-30"
---

# Analytics

Visitor analytics for the pages that show your articles, wherever they are served: the setup
(site id and snippet), turning it on or off, the domains that count, the visitor report, and a
check that the script is installed on a page. Cookieless: a visitor is counted once a day from a
hash under a salt that changes daily, and no cookie or browser storage id is set. Hosted Writavo
blogs carry the script automatically; anywhere else, paste the `snippet` from
`GET /analytics/setup` inside `<head>`. Guide: https://writavo.com/docs/analytics.

The script tag's attributes:

- `data-site` (required): the Site's public id, `wa_` followed by 24 hex characters.
- `data-include` (optional): a regular expression; only pages whose path matches it are counted,
  for a blog inside a bigger app (`data-include="^/blog(/|$)"`). An invalid pattern is ignored
  and every page counts.
- `data-api` (optional): the full URL to send events to instead of the collector's own
  `/api/a/e`, for a setup that forwards them itself.

It counts one pageview on load and on every client-side navigation, and the time the page was
visible when the reader leaves. It stays silent on localhost, 127.x, 0.0.0.0 and file: pages, in
automated browsers (`navigator.webdriver`, Cypress, PhantomJS, Nightmare), and for a visitor who
sets `localStorage.writavo_ignore = "true"` (to exclude your own visits). A Content Security
Policy must allow the collector origin in both `script-src` and `connect-src`. The collector
answers 202 to every event, accepted or not, so it cannot tell you whether an install works: use
`POST /analytics/install-check`, or `install` in `GET /analytics/setup`, which shows the last
event that arrived and any hosts that were turned away.

Base URL: `https://api.writavo.com/v1`

### GET /analytics/setup

Read the analytics setup and install status

- **Operation id**: `getAnalyticsSetup`
- **Scope**: `insights:read`
- **Permission**: `analytics.view`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Everything needed to install visitor analytics and confirm it works: the Site's public
`site_id`, the ready-made `snippet` to paste inside `<head>` on every page that shows your
articles, whether collecting is on, the domains whose visits count (`allowed_domains`: the
primary domain and its subdomains, verified custom domains, reverse-proxy targets and your
`extra_domains`), and `install`: when the last event arrived and from which page, how many
arrived in the last 24 hours, and the hosts whose visits were turned away because they are
not on the list.

The snippet always points at the public collector (`collector_origin`,
`https://blog.writavo.com` in production), never at a development address. Hosted Writavo
blogs carry it automatically. To check a page, use `POST /analytics/install-check`.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | AnalyticsSetup | The setup. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### PATCH /analytics/settings

Turn analytics on or off, or change the extra domains

- **Operation id**: `updateAnalyticsSettings`
- **Scope**: `site:write`
- **Permission**: `website.settings`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Send `enabled`, `extra_domains`, or both. `extra_domains` replaces the whole list (up to 20)
and each one also counts its subdomains: add every domain the script runs on that is not
already the Site's primary domain, a verified custom domain or a reverse-proxy target (an app
on another domain, a staging host you want counted). A pasted URL is reduced to its host.
While `enabled` is false every visit is dropped. Returns the same shape as
`GET /analytics/setup`. Recorded in the audit log.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | no | Count visits (true) or drop them all (false). |
| `extra_domains` | string[] | no | Replaces the whole list. Bare domains (`docs.example.com`); a pasted URL is reduced to its host. Each counts its subdomains too. `[]` clears the list. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | AnalyticsSetup | The setup after the change. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X PATCH https://api.writavo.com/v1/analytics/settings \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "extra_domains": [
    "docs.example.com"
  ]
}'
```

### GET /analytics/visitors

Read the visitor report

- **Operation id**: `getVisitorAnalytics`
- **Scope**: `insights:read`
- **Permission**: `analytics.view`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Visitors, pageviews, bounces and engaged time for a range of UTC days, with the previous range
of the same length for comparison, one row per day (`daily`, every day of the range, zeros
included) and the top 50 values of each breakdown: pages, entry pages, sources, referrers, UTM
source, medium and campaign, country, region, city, device, browser and operating system.

Default range: the last 30 days (`to` today, `from` 29 days before). At most 400 days. Numbers
are rolled up from the raw events every 15 minutes, so a visit shows here up to 15 minutes
after it happens; `GET /analytics/setup` shows raw arrivals at once. A visitor is counted once
per day per Site from a daily-salted hash; no cookies are used.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `from` | query | date | no | First UTC day, YYYY-MM-DD. Default 29 days before `to`. |
| `to` | query | date | no | Last UTC day, YYYY-MM-DD. Default today. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | VisitorAnalytics | The report. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl https://api.writavo.com/v1/analytics/visitors \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /analytics/install-check

Check that the analytics script is installed on a page

- **Operation id**: `checkAnalyticsInstall`
- **Scope**: `insights:read`
- **Permission**: `analytics.view`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Fetch one page the way a search engine would (server HTML, no JavaScript) and check, in this
order: `page_reachable` (it answers 2xx with HTML), `tag_present` (a script whose src ends in
`/api/a/s`), `site_id_matches` (its `data-site` is this Site's id, not another Site's),
`collector_public` (it loads over https from the public collector, not localhost),
`include_matches` (when the tag has `data-include`, the pattern is valid and matches this
page's path), `collecting_enabled` (collecting is on, and the host the page ends on counts)
and `recent_events` (an event arrived in the last 24 hours). Each check has a sentence saying
what to fix.

`status` is `failed` when the page will not be counted (`page_reachable`, `tag_present`,
`site_id_matches` or `collecting_enabled` failed), `warning` when anything else failed, and
`ok` otherwise. No events yet is only a warning: the script stays silent on localhost, 127.x
and file: pages and in automated browsers, so a fresh install needs one real visit in an
ordinary browser. A script added only in the browser after the page loads (a tag manager, a
lazy `next/script`) is not in the server HTML, so `tag_present` fails even though visits may
still arrive; `recent_events` and `GET /analytics/setup` show whether they do.

`url` defaults to the Site's first verified custom domain, else its primary domain. Its host
must be one this Site counts visits from (`allowed_domains` in `GET /analytics/setup`),
otherwise `422` on `url`: add the domain with `PATCH /analytics/settings` first. Free; one page
fetch of at most 2 MB, following up to three redirects.

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | null | no | The http(s) address of a page that should carry the script. Its host must be one this Site counts visits from. Omitted: the Site's first verified custom domain, else its primary domain. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | AnalyticsInstallCheck | The verdict, with every check. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/analytics/install-check \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://example.com/blog/how-to-choose-a-headless-cms"
}'
```
