# UsherStats documentation, every page
---
title: UsherStats documentation
description: How to install UsherStats, read its dashboards, and use its API, MCP server, bot protection and proxy mode.
nav: Overview
---
# UsherStats documentation
UsherStats is web analytics that counts people, not bots. It shows the readers of your site, the crawlers and AI bots
that fetch it, and the AI assistants that send readers your way, without cookies or personal data. It can also protect
your site from the bots you do not want.
## Start here
- [Getting started](getting-started.md): your first pageview in five minutes.
- [Installing](installing.md): the snippet, a first-party path on your own domain, the Worker SDK, or proxy mode.
- [Concepts](concepts.md): what counts as a pageview, a session, a bot, and internal traffic.
## Use it
- [Dashboards explained](dashboards.md)
- [Goals and A/B tests](goals-and-experiments.md)
- [Bot protection](bot-protection.md)
- [Proxy mode](proxy-mode.md)
- [Search Console and Bing](search-console-and-bing.md)
## Build on it
- [API and tokens](api-and-tokens.md): everything an account can do, over HTTP.
- [Agents and MCP](agents-and-mcp.md): give an AI agent access to your analytics.
- [Exporting your data](data-export.md)
## Your account
- [Privacy and compliance](privacy-and-compliance.md)
- [Billing and plans](billing-and-plans.md)
- [Troubleshooting](troubleshooting.md)
Every page here is also available as Markdown: add `.md` to its address, or read
[llms.txt](https://docs.usherstats.com/llms.txt) for the list and `llms-full.txt` for everything in one file.
---
---
description: From a new account to your first counted pageview in about five minutes.
---
# Getting started
Five minutes from a new account to your first pageview.
## 1. Create an account
Sign up at [app.usherstats.com/signup](https://app.usherstats.com/signup) with your email address and a password, and
confirm the email we send you. Every feature is on every plan, including Free; you can add a card later.
## 2. Add your site
In the dashboard, choose **Add site** and give:
- a **name** (for you; it is not shown anywhere public);
- every **hostname** the site is served on, such as `example.com` and `www.example.com`. UsherStats only accepts
reports from pages on these hostnames, so a missing one means missing visits;
- your **time zone**, which decides where a day begins in your charts.
The site's **Install** page then shows its public key, which starts with `pk_`.
## 3. Add the snippet
Paste this into the `
` of every page, with your own key:
```html
```
That is the whole installation for human analytics. To serve it from your own domain, to see crawlers and AI bots, or to
turn on bot protection, see [Installing](installing.md).
## 4. Visit your site
Open one of your pages in an ordinary browser window, then switch to another tab or close it: the page reports when it
is hidden, so time on page and scroll depth are complete. The site's Install page shows when the first report arrives,
and the visit appears in the **People** view within a minute or two.
If nothing arrives:
- Your browser may send **Global Privacy Control** (Brave and DuckDuckGo do by default, and some extensions add it).
UsherStats does not record those browsers at all. Try another.
- If you have marked your browser as internal, your visit is under **Internal**, not People. That is working as intended.
- If you browse through a VPN that runs on a cloud provider, your visit is counted as a hosting-network visit, under
**Crawlers**.
[Troubleshooting](troubleshooting.md) covers the rest.
## Next
- [Concepts](concepts.md): what UsherStats counts, and what it leaves out.
- [Goals and A/B tests](goals-and-experiments.md): count signups and downloads.
- [Installing](installing.md): see the crawlers and AI bots reading your site.
---
---
description: Install UsherStats with the snippet, a first-party path on your own domain, the Worker SDK, or proxy mode.
---
# Installing
There are three ways to install UsherStats, plus proxy mode, and they build on each other.
| Way | What you get | What you change |
|---|---|---|
| [Snippet](#the-snippet) | Human analytics, goals, journeys, A/B tests | One tag in your pages |
| [First-party path](#a-first-party-path) | The same, served from your own domain, so blockers aimed at third parties leave it alone | A route on your server |
| [Worker SDK](#the-worker-sdk) | All of the above, plus crawler and AI-bot analytics and bot protection | Your Cloudflare Worker |
| [Proxy mode](#proxy-mode) | The same as the Worker SDK, with no code | A DNS record |
Your site's **public key** (`pk_…`) and **server secret** (`sk_live_…`) are on its Install page in the dashboard. The
public key is public by design. Keep the server secret on your server: it is what lets UsherStats believe what your
server says about its visitors.
## The snippet
```html
```
Put it in the `` of every page. Optional attributes:
- `data-page-type="article"`: a short label for the kind of page, for the dashboard's page-type views.
- `data-journeys="off"`: send pageviews and events only, not journeys.
If your site sends a Content Security Policy, allow `https://collect.usherstats.com` in `script-src` and `connect-src`.
## A first-party path
Served from your own domain, the script and its reports are first-party: nothing for a blocker to drop as a third party.
Your server forwards `/_us/` to `https://collect.usherstats.com/`, and the snippet says so:
```html
```
**Your server must tell UsherStats who the visitor is.** Behind your proxy, UsherStats sees your server, not the
visitor, and a server's network is a hosting network, which UsherStats counts as automated. So every forwarded request
carries your server secret and the visitor's details:
| Header | Value |
|---|---|
| `x-usher-proxy` | your server secret, `sk_live_…` |
| `x-usher-client-ip` | the visitor's IP address |
| `x-usher-client-country` | the visitor's two-letter country code |
| `x-usher-client-asn` | the visitor's network number (AS number) |
| `x-usher-client-as-org` | the visitor's network name, such as `Comcast Cable Communications, LLC` |
UsherStats uses these only with a valid secret for that site. **If your server cannot supply the visitor's network
(AS number and name), use the snippet directly instead**: otherwise your readers will be counted as hosting-network
traffic. On Cloudflare, the Worker SDK fills all of them for you. Elsewhere, a GeoIP database with network data (for
example MaxMind's GeoLite2 ASN and Country) provides them.
### Cloudflare Workers
Use the [Worker SDK](#the-worker-sdk): it serves `/_us/` and adds the snippet to your pages for you.
### Next.js
Most Next.js hosts do not tell your code the visitor's network, so for Next.js the simplest correct install is the
direct snippet, in your root layout:
```jsx
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
{children}
);
}
```
If your Next.js site runs behind Cloudflare, put a Worker with the [Worker SDK](#the-worker-sdk) in front of it for a
first-party path. If your host gives you the visitor's network in request headers, you can forward `/_us/` with a
route handler that sets the headers above, as in the Nginx example.
### Nginx
With the [GeoIP2 module](https://github.com/leev/ngx_http_geoip2_module) and the GeoLite2 ASN and Country databases
loaded as `$geoip2_asn`, `$geoip2_as_org` and `$geoip2_country`:
```nginx
location /_us/ {
proxy_pass https://collect.usherstats.com/;
proxy_ssl_server_name on;
proxy_set_header Host collect.usherstats.com;
proxy_set_header Cookie "";
proxy_set_header x-usher-proxy "sk_live_…";
proxy_set_header x-usher-client-ip $remote_addr;
proxy_set_header x-usher-client-country $geoip2_country;
proxy_set_header x-usher-client-asn $geoip2_asn;
proxy_set_header x-usher-client-as-org $geoip2_as_org;
}
```
If Nginx is itself behind a proxy or load balancer, use the visitor's address it forwards (for example with the
`realip` module) rather than the load balancer's.
### Caddy
With a GeoIP plugin that sets the visitor's country and network as placeholders (named here
`{geoip.country}`, `{geoip.asn}` and `{geoip.as_org}`; use your plugin's names):
```caddy
handle_path /_us/* {
reverse_proxy https://collect.usherstats.com {
header_up Host collect.usherstats.com
header_up -Cookie
header_up x-usher-proxy {env.USHERSTATS_SECRET}
header_up x-usher-client-ip {remote_host}
header_up x-usher-client-country {geoip.country}
header_up x-usher-client-asn {geoip.asn}
header_up x-usher-client-as-org {geoip.as_org}
}
}
```
Both examples drop your site's cookies on the way: UsherStats needs none of them. (The one exception is the cookie
that marks your own team's browsers as internal; see [Concepts](concepts.md#internal-traffic). The Worker SDK forwards
that one alone.)
## The Worker SDK
For a site served by a Cloudflare Worker, wrap your handler:
```js
import { withUsherStats } from '@usherstats/worker';
const site = async (request) => new Response('HomeHello', {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
export default {
fetch: withUsherStats(site, { pageType: (path) => (path.startsWith('/blog/') ? 'article' : 'page') }),
};
```
Put the server secret in the Worker secret `USHERSTATS_SECRET` and the public key in `USHERSTATS_SITE_KEY`. The SDK:
- serves the snippet and its reports from `/_us/` on your hostname, with the visitor's network details, and adds the
snippet to your HTML pages;
- records every request your Worker answers, people and crawlers alike, and sends the records in batches after the
response, so no page waits for UsherStats;
- runs your site's [bot protection](bot-protection.md) before your code.
If UsherStats is slow or unreachable, your site is served as if it were not there. The options, and how bot protection
decides, are on the [Bot protection](bot-protection.md#setting-it-up-in-a-cloudflare-worker) page.
## Proxy mode
If your site is not on Cloudflare Workers, or you would rather not change it, point a hostname at UsherStats and it
sits in front of your server, with analytics and bot protection and no code. See [Proxy mode](proxy-mode.md).
## Checking the install
The site's Install page shows when the last pageview and the last server record arrived. Requests your server or proxy
records count towards your plan's server requests; pageviews count towards its human pageviews (see
[Billing and plans](billing-and-plans.md)).
---
---
description: What UsherStats counts as a pageview, a session, a bot, internal traffic and an AI referral, and how long data is kept.
---
# Concepts
UsherStats sorts every visit into one of three lanes and counts each one separately, because one combined number
answers none of the questions worth asking.
| Lane | Who | Where you see it |
|---|---|---|
| People | Readers in real browsers | People, Behaviour |
| Crawlers | Search engines, AI fetchers, SEO tools, scrapers, scripts | Crawlers, Bot protection |
| Internal | Your own team, tools and checks | Internal |
## What counts as a pageview
A pageview is counted when a real browser confirms it: the snippet reports the page when it is hidden or closed (or
after five minutes, for a tab left open), with how long it was open, how far it was scrolled and whether the reader did
anything. A request a crawler makes is never a pageview, because a crawler does not run the script.
A confirmed pageview counts under **People** unless it is one of these, which go to Crawlers or Internal instead:
- it came from your own team (see [internal traffic](#internal-traffic));
- it came from a **hosting network**: cloud and server providers, where people rarely browse from, but scripts with
browser-like names often do;
- the browser reported a server's clock (`UTC` rather than a place) as its time zone;
- it is past the twentieth page of one session: no person reading at a human pace goes that deep in one tab, but a
scripted browser touring the site does;
- [bot protection](bot-protection.md) judged the visitor to be automated, even if it was only logging that decision.
Browsers that send **Global Privacy Control** are not recorded at all, so they appear nowhere.
The snippet currently reports one pageview per full page load. Route changes inside a single-page app are not
reported as separate pageviews.
## Sessions
A session is **a browser tab's visit**: it starts on the tab's first page and lasts as long as the tab stays on your
site. A page opened in a new tab from one of your own pages belongs to the visit it was opened from.
UsherStats keeps nothing between visits: no cookie, no identifier that lasts beyond the tab. The page counter lives in
the tab's session storage, which the browser clears when the tab closes. A person who comes back tomorrow, or opens
your site in a second tab from elsewhere, is a new session. That is the price of storing nothing about people, and it
is the same for every site, so comparisons hold.
## Internal traffic
Your own visits while you build and check your site are not readers. UsherStats recognises them three ways, per site:
- **Tools**: scripts, smoke tests and uptime checks that send a signed `x-usher-internal` header. The site's settings
show how to sign it; a signature is good for ten minutes, on the one hostname it was made for.
- **Browsers**: a team member can mark their browser from the site's settings. Through a first-party path, this sets
a cookie on your own domain, in that browser only, and its visits count as Internal.
- **Networks**: an office or VPN network you list in the site's settings. The weakest of the three, because readers
can share a network with you.
Internal traffic is counted, just not as readers: the **Internal** view shows it.
## Bots
With the [Worker SDK](installing.md#the-worker-sdk) or [proxy mode](proxy-mode.md), UsherStats sees every request
your site answers, not only the ones that run a script. Each request is classified:
- **Search engines**: Googlebot, Bingbot and others, verified by Cloudflare where possible. A request using a search
engine's name without being verified is an impersonator.
- **AI fetchers**: GPTBot, ClaudeBot, PerplexityBot and others, both the crawlers that gather training data and the
fetchers that read a page because someone asked an assistant about it.
- **SEO tools, monitors, feed readers, link previews and scrapers**, each by name where it gives one.
- **Unnamed automation**: browser-like requests from hosting networks, or with the marks of software that never runs
a page's scripts.
The **Crawlers** view shows who came, how often, and what they read.
## AI referrals
A reader who arrives from an AI assistant is a person, counted under People, with the assistant as the source:
ChatGPT, Perplexity, Claude, Gemini and Copilot are recognised by the referring site, and also by the `utm_source` tag
they add to links, because many of those visits arrive with no referrer at all.
## Sources
Every People visit has a source and medium: search, social, referral, campaign (from `utm_source`, `utm_medium` and
`utm_campaign`), AI, or direct. Only those three campaign tags are taken from a page's address; the rest of the query
string is never sent. A link from one of your own hostnames to another is the same site, not a referral.
## Data retention
UsherStats keeps your data **for as long as your account exists**, on every plan: the raw records and the daily
totals built from them. You can delete a site's data, or set a shorter retention for it, in the site's settings, and
[export](data-export.md) everything at any time.
---
---
description: What each UsherStats dashboard view shows, People, Crawlers, Internal, Behaviour, Search and Bot protection.
---
# Dashboards explained
Each site has a set of views. All of them share a date range (with a comparison to the period before) and filters:
pick a page, a source, a country or a device anywhere and the whole view follows.
## People
Your readers: [confirmed pageviews](concepts.md#what-counts-as-a-pageview) in real browsers, with bots, hosting
networks and your own team taken out.
- **Pageviews, sessions and pages per session** over time.
- **Top pages**, with time on page and scroll depth, so you can tell pages people read from pages they leave.
- **Sources**: search engines, social sites, referrals, campaigns and AI assistants, each with the pages they sent
readers to.
- **Countries, devices and languages.**
## Crawlers
Every request your server answered that was not a person, from the [Worker SDK](installing.md#the-worker-sdk) or
[proxy mode](proxy-mode.md). The snippet alone cannot see crawlers, so this view is empty until one of those is set up.
- **Who**: each named crawler and AI fetcher, grouped by kind (search, AI, SEO tools, monitors, scrapers), with
verified search engines marked.
- **What they read**: the pages each one fetched and how often, and which pages no crawler has reached.
- **AI**: the AI fetchers next to the readers AI assistants sent you, so you can see what an assistant read and what it
sent back.
## Internal
Your own team's visits, tools and checks, kept out of the other views. Useful to confirm a deploy check ran, and to see
that your own browsing is not inflating your numbers.
## Behaviour
What readers do across pages, from the journeys the snippet records:
- **Paths**: the most common routes through the site, and a **Sankey** diagram from an entry page or towards a goal.
- **Goals**: how many sessions reached each goal, and from where. See
[Goals and A/B tests](goals-and-experiments.md).
- **Experiments**: each A/B test's variants side by side.
Groups of fewer than five sessions are never shown, so a path cannot single out one person.
## Search
Your Google Search Console and Bing Webmaster data beside your traffic: queries, impressions, clicks and positions,
by page. Connected with your own credentials; see [Search Console and Bing](search-console-and-bing.md).
## Bot protection
What [bot protection](bot-protection.md) decided: requests served, challenged and refused, by rule, by crawler and by
network, and how many challenged visitors passed. In `log` mode it shows what it *would* have done, so you can tune
the rules before turning them on.
---
---
title: Goals and A/B tests
description: Count signups, downloads and clicks as goals, and run A/B tests on people only, without cookies.
nav: Goals & A/B tests
---
# Goals and A/B tests
## Events
An event is something a reader did that you want to count: a signup, a download, a click on a pricing link. Send one
of two ways.
Mark an element; a click on it sends the event:
```html
Download the report
```
`data-us-label` is optional and tells events of one name apart. A link's destination host is sent with the event, so
outbound clicks show where readers went.
Or call it from your own code:
```js
usherstats.track('signup', { label: 'pricing page', value: 1 });
```
`value` is a number and defaults to 1. `usherstats.track` always exists once the snippet has loaded, and does nothing
in a browser that sends Global Privacy Control.
Never put personal data in an event's name or label: no email addresses, names or account ids.
## Goals
A goal is an event (or a page) you have named as a goal in the site's settings. Goals appear in the People and
Behaviour views, with the number of sessions that reached each one, their sources, and the paths that led there.
## A/B tests
Mark the variants in your page. Each session is assigned one variant, which is shown while the others are hidden:
```html
Analytics that count people
Know who reads your site
```
- `data-exp` names the experiment; `data-variants` lists the variants; each child with `data-v` is one variant.
- A session is assigned by hashing it with the experiment's name, so the same tab always sees the same variant and
nothing is stored to remember it. The same experiment marked in several places shows the same variant in each.
- A browser that sends Global Privacy Control is shown the first variant and is not counted.
Give every variant but the first the `hidden` attribute, as above, so a page shows one variant before the script
runs; the script then shows the session's variant, hides the rest, and sets `data-variant` on the block.
The **Experiments** part of the Behaviour view shows, for each variant, the sessions that saw it, how many reached the
goal you choose, the rate, and whether the difference between the two largest variants is statistically significant
(a two-proportion test). Only People sessions count: bots and your own team never enter an experiment's results.
Because a session is one tab, a person who opens a second tab is a second session and may see the other variant. That
dilutes a difference rather than creating one.
---
# Bot protection
UsherStats can decide, for every request to your site, whether to serve it, ask the visitor to prove they are a person,
or refuse it. It runs inside your own Cloudflare Worker (the Worker SDK) or in front of your origin (proxy mode), before
your code, and it is built so a mistake of ours never takes your site down: anything we cannot answer quickly is
treated as "serve the request".
## Modes
Each site has one mode, set in the dashboard or the API:
| Mode | What happens |
|---|---|
| `off` | Nothing is decided. Requests are still recorded for analytics. This is the default. |
| `log` | Every request is served. Each request record says what the rules *would* have done (`log: would block: ...`). Start here. |
| `challenge` | Requests the rules challenge are sent to a short check; requests they deny get a `403`. |
| `block` | As `challenge`, but where a challenge would be shown the request gets a `403` instead. |
Run a site in `log` for a few days and read the Crawlers and Bot protection views before switching to `challenge`.
## What is decided, in order
1. `robots.txt` and `/.well-known/security.txt` are always served: a crawler that cannot read your rules cannot obey them.
2. **Your rules**, top to bottom; the first rule that matches decides.
3. **Verified search engines** (Googlebot, Bingbot and the others Cloudflare verifies) are always served, unless one of
your rules names them (see below).
4. **Impersonators**: a request using the name of a crawler Cloudflare can verify, which Cloudflare has *not*
verified, is refused. Measured on our first customer's sites, every unverified "Googlebot" was someone else.
5. Other verified bots are served. Bots that say what they are but cannot be verified follow your `bots` setting.
6. **Browsers.** A person who passed a check in the last day is served. Otherwise a browser is checked when:
it comes from a hosting network (cloud servers, where people rarely browse from); its client software is on the
shared list of software that never runs pages' scripts; or its behaviour over the last day shows at least two
independent signs of automation (pages read faster than a person reads, probing for files that are not there,
pages loaded with no sign the page was ever seen). The network alone never refuses anyone: in `block` mode a
browser on a hosting network is served.
## Rules
```json settings
{
"mode": "challenge",
"bots": "allow",
"rules": [
{ "action": "allow", "label": "partner feed", "match": { "path": "/feeds/", "asn": 13335 } },
{ "action": "deny", "label": "no AI training", "match": { "category": "ai-training" } },
{ "action": "challenge", "label": "check browsers at checkout", "match": { "path": "/checkout", "kind": "browser" } },
{ "action": "deny", "match": { "path": ["/wp-login.php", "/xmlrpc.php"] } }
]
}
```
A rule has an `action` (`allow`, `challenge` or `deny`), an optional `label` (it appears in your request records), and
a `match`. Every field in a match must hold:
| Field | Matches | Example |
|---|---|---|
| `bot` | a bot's name, one or a list | `"GPTBot"` |
| `category` | `search-engine`, `ai-search`, `ai-assistant`, `ai-training`, `preview`, `seo`, `monitor`, `archiver`, `tool`, `other` | `"ai-training"` |
| `ai` | any AI crawler or AI fetcher | `true` |
| `kind` | `browser`, `bot` or `verified-bot` | `"bot"` |
| `verified` | whether Cloudflare verified the bot | `false` |
| `asn` | network numbers | `[16276, 24940]` |
| `country` | two-letter country codes | `["CN", "RU"]` |
| `path` | path prefixes | `"/admin"` |
**Search engines are protected from broad rules.** A rule that matches only by `asn`, `country` or `path` does not apply
to a verified search engine, so a rule written for scrapers cannot take your site out of Google by accident. A rule
that names them (`bot`, `category`, `ai`, `kind` or `verified`) does apply: `{"category": "search-engine", "path":
"/drafts/"}` keeps search engines out of `/drafts/`.
**`bots`** sets what happens to a bot no rule names: `allow` (the default), `challenge`, or `deny`. To let in only
the bots you name, set `"bots": "deny"` and add `allow` rules above it for the ones you want.
A passed check satisfies `challenge` rules, never `deny` rules.
```js
import assert from 'node:assert/strict';
import { normalizeConfig, decide } from '@usherstats/bot';
const settings = normalizeConfig({
mode: 'challenge',
rules: [{ action: 'deny', label: 'no AI training', match: { category: 'ai-training' } }],
});
const gptbot = new Request('https://shop.example/', { headers: { 'user-agent': 'Mozilla/5.0 (compatible; GPTBot/1.1; +https://openai.com/gptbot)' } });
gptbot.cf = { verifiedBotCategory: 'AI Crawler', country: 'US', asn: 8075 };
assert.equal(decide(gptbot, { config: settings }).action, 'block');
assert.equal(decide(gptbot, { config: settings }).reason, 'rule: no AI training');
// A person's assistant opening a link for them is not training, and is served.
const assistant = new Request('https://shop.example/', { headers: { 'user-agent': 'Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)' } });
assistant.cf = { verifiedBotCategory: 'AI Assistant', country: 'US', asn: 8075 };
assert.equal(decide(assistant, { config: settings }).action, 'allow');
```
Every decision is `{ action, reason, risk, signals }`: `risk` is the 0-100 score for a browser (`-1` for a bot) and
`signals` says which behaviours were seen.
## The check
Visitors who are challenged are sent to `challenge.usherstats.com`, where a Cloudflare Turnstile check usually passes
in about a second, often without anything to click. They are then sent back to the page they asked for, through
`/_us/pass` on your own hostname, which sets one cookie, `us_pass`:
- it is first-party (your domain), `HttpOnly`, `Secure`, `SameSite=Lax`, and lasts a day;
- it is signed for your site and for that browser on that network, so copying it to another device or a script does
nothing;
- it is set only after a visitor was challenged; no other visitor gets a cookie from UsherStats.
The check only ever returns visitors to hostnames registered for your site **and verified**: add every hostname your
site uses, publish the TXT record each one shows (`_usherstats.`), and verify it
(`POST /v1/sites/{id}/hostnames/{hostname}/verify`). Proxy-mode hostnames are verified by Cloudflare's validation.
Without this, a challenge for an unverified hostname is refused, so anyone listing a hostname they do not own cannot
use the check page to send people there.
## Setting it up in a Cloudflare Worker
```js
import { withUsherStats } from '@usherstats/worker';
const site = async (request) => new Response('ShopHello', {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
export default {
fetch: withUsherStats(site, { pageType: (path) => (path.startsWith('/blog/') ? 'article' : 'page') }),
};
```
Put the site's server secret in the Worker secret `USHERSTATS_SECRET` and its public key in `USHERSTATS_SITE_KEY`
(both on the site's Install page). The wrapper:
- serves the analytics script and its reports from `/_us` on your own hostname, and adds the script to HTML pages;
- records every request (with Cloudflare's network and TLS details) and sends the records to UsherStats in batches,
after the response, so no page waits for us;
- reads your bot protection settings at most once a minute and decides before your code runs;
- answers `/_us/pass` for visitors returning from a check.
Options: `pageType(path)` labels pages for the dashboard; `botProtection: false` turns protection off in this Worker
whatever the mode; `firstParty: '/_stats'` moves the `/_us` routes if your site already uses that path;
`inject: false` stops the script being added (requests are still recorded).
**If UsherStats is slow or down,** your site is served as if protection were off: settings that cannot be read in a
second, a visitor record that does not answer quickly, and a failed upload of records are all ignored. Errors thrown by
your own code are passed on to Cloudflare exactly as without the wrapper.
## Privacy
Bot protection keeps a short memory per visitor: how many pages, how fast, whether pages were seen. It is filed under a
one-way code made from the visitor's network, browser and your site with a secret that changes every day, so it cannot
be turned back into an address, cannot follow a person from one day to the next or from one site to another, and is
deleted 24 hours after the visitor's last request. The shared list of client software that never runs pages' scripts
holds only software fingerprints, never any site's traffic. It is built only from what UsherStats observed itself
(proxy-mode requests and the connections of page-script reports), never from what a site's server reports, and a
fingerprint goes on it only with evidence from several established customers (on a paid plan, or a month old with
traffic on a week of days), no one of whom can outweigh the rest. Mainstream browsers' fingerprints are never put on it.
---
# Proxy mode
Proxy mode puts UsherStats in front of your site without any code: you point a hostname at us, and every request to it
gets analytics and bot protection and is then forwarded to your server (the origin). Use it when your site is not on
Cloudflare Workers, or you would rather not change it.
## Setting it up
1. In the dashboard (or the API), add the hostname to your site in proxy mode and give the **origin**: where your site
really lives, such as `origin.example.com` or `https://example-app.pages.dev`.
2. Create the DNS records it shows you. The CNAME is what sends traffic to us; the TXT records are optional, and let
the hostname go live before you move the CNAME, with no gap:
```json records
[
{ "type": "CNAME", "name": "shop.customer.example", "value": "proxy.usherstats.com", "purpose": "routing" },
{ "type": "TXT", "name": "_cf-custom-hostname.shop.customer.example", "value": "5cc07c04-ea62-4a5a-95f0-419334a875a4", "purpose": "ownership" },
{ "type": "TXT", "name": "_acme-challenge.shop.customer.example", "value": "810b7d5f01154524b961ba0cd578acc2", "purpose": "certificate" }
]
```
3. Wait for the status to become **active**. A certificate is issued for your hostname automatically once the CNAME is
in place, usually within minutes; until then the status shows what is still missing (for example, a CAA record that
does not allow the certificate authority).
Each plan includes a number of proxy-mode hostnames (Free 1, Starter 3, Growth 10, Business 50); beyond that, $1 per
hostname per month, up to 500 per workspace on any plan (write to support@usherstats.com for more). Proxy hostnames
can be added or removed 30 times an hour per workspace.
A proxied hostname belongs to one site. It goes live once Cloudflare has validated it (the CNAME, or the TXT
records the answer lists); one still not validated after 72 hours is released: another site may then add it, and a
daily job removes it.
## The origin
The origin is a hostname, optionally with `https://` or `http://` and a port. It cannot be the proxied hostname itself
(that would loop), a `usherstats.com` hostname, or a bare IP address, and the port must be one Cloudflare can reach
(80, 8080, 8880, 2052, 2082, 2086, 2095 for http; 443, 2053, 2083, 2087, 2096, 8443 for https).
## What your origin receives
- The request as the visitor sent it: method, path, query string, body and headers.
- `X-Forwarded-Host` (your hostname), `X-Forwarded-Proto`, and the visitor's address appended to `X-Forwarded-For`.
- The `Host` header is the origin's own hostname. If your server picks the site by host name, configure it to answer
for the origin hostname, or read `X-Forwarded-Host`.
- Not the `us_pass` cookie (the bot protection pass), and not the headers that only describe the connection to us.
## What visitors receive
Your origin's response, streamed as it was sent: status, headers, cookies and body. HTML pages also get the analytics
script. Nothing is cached beyond what your origin's own `Cache-Control` allows. Redirects are passed on rather than
followed, and a redirect to your origin's hostname is rewritten to your public hostname, so visitors never see the
origin's name.
## Analytics and bot protection
Proxied requests are recorded and protected exactly as with the Worker SDK (see [Bot protection](bot-protection.md)):
the same modes, rules and check. In proxy mode the analytics script is served from your hostname and sends its page
reports straight to `collect.usherstats.com`, so pageviews carry the visitor's own country and network. If your
origin cannot be reached, visitors get a short `502` page from us, and the failed request is recorded.
## Limits
- WebSocket connections are not proxied.
- Request records count toward the plan's server requests, as with the Worker SDK.
---
---
title: Search Console and Bing setup
description: Connect Google Search Console and Bing Webmaster Tools to UsherStats with your own access, two ways for Google.
nav: Search Console & Bing
---
# Search Console and Bing setup
UsherStats shows your search data beside your traffic, fetched with access you give it and can take back at any time.
Connecting needs the `search:write` scope (owners, admins and members have it). Credentials are encrypted before they
are stored and are used only to fetch your own data.
## Google Search Console
Choose one of two ways. Both give read-only access to the properties you choose.
### Option 1: add the UsherStats service account to your property
The simplest way: you add UsherStats as a user of your property, and can remove it whenever you like.
1. In UsherStats, open the site's **Search** settings and copy the service-account address shown there.
2. In [Search Console](https://search.google.com/search-console), open the property, then **Settings → Users and
permissions → Add user**.
3. Paste the address and choose the **Restricted** permission (read-only is all UsherStats needs).
4. Back in UsherStats, pick the property from the list and save.
### Option 2: upload your own service-account key
Use this if your organisation does not allow adding outside accounts, or you want the access to live in your own Google
Cloud project.
1. In the [Google Cloud console](https://console.cloud.google.com/), choose or create a project and enable the
**Google Search Console API**.
2. Create a **service account** (no roles are needed in the project) and create a **JSON key** for it.
3. Add the service account's email address to your Search Console property as in option 1, step 2, with the
**Restricted** permission.
4. In UsherStats, open the site's **Search** settings, choose **Use my own key**, upload the JSON file, and pick the
property.
Keep the JSON file safe or delete it once uploaded; UsherStats keeps its own encrypted copy. To revoke access, delete the
key in Google Cloud or remove the user from the property.
### Which property
A **domain property** (`sc-domain:example.com`) covers every hostname and protocol of your domain; a **URL-prefix
property** covers only addresses under its prefix. Pick the one that matches the hostnames of your UsherStats site.
## Bing Webmaster Tools
1. In [Bing Webmaster Tools](https://www.bing.com/webmasters), open **Settings → API access** and generate an
**API key**.
2. In UsherStats, open the site's **Search** settings, paste the key under Bing, and pick the site.
## What you get
Queries, impressions, clicks, click-through rate and average position, by page and by week, in the **Search** view.
Search engines publish this data with a delay of a few days, so the latest days fill in later.
---
---
title: API and tokens
description: The UsherStats REST API, API tokens and their scopes, errors, and where the full reference lives.
nav: API & tokens
---
# API and tokens
Everything you can do in the dashboard you can do through the API: read analytics, manage sites and their keys, set
bot protection rules, goals and experiments, manage your team and tokens, and export data. The dashboard itself is
built on the same API.
## Basics
- Base address: `https://api.usherstats.com`, with every route under `/v1/`.
- JSON in and out.
- The full reference is the OpenAPI 3.1 description at `GET /v1/openapi.json`, generated from the routes the API
serves, so it is never out of date. Load it into any OpenAPI tool to browse it or to generate a client.
## Tokens
Programs use API tokens; people use the dashboard. Send a token as a bearer token:
```sh
curl -s https://api.usherstats.com/v1/me -H "Authorization: Bearer us_live_…"
```
- A token **belongs to one workspace** and can do whatever its scopes allow there, including everything an account
can when it holds `account:admin`.
- **Scopes** have the form `module:action`: `analytics:read`, `sites:read`, `sites:write`, `bot:read`, `bot:write`,
`search:read`, `search:write`, `members:read`, `members:write`, `tokens:read`, `tokens:write`, `billing:read`,
`billing:write`, `export:read`, and `account:admin` for everything.
- A token can be **limited to some sites**, and can **expire**.
- A token is **shown once**, when it is created, and stored only as a hash. Lists show its first characters so you
can tell tokens apart, and when it was last used.
- Give each program its own token with the fewest scopes it needs, and revoke it when the program is retired.
Create tokens in the dashboard under **Settings → API tokens**, or through the API; see [API tokens](api/tokens.md).
## Errors
Every error is `application/problem+json`, with a `title` that says what went wrong and what to do, and the HTTP
status that fits: `400` for a request the API cannot accept (naming the field), `401` without a valid token, `403`
when the token lacks the scope or the site, `404` for something that does not exist in your workspace, and `429` when
you are sending too fast (wait the number of seconds in `Retry-After`).
## Reference pages
- [Accounts and sign-in](api/auth.md)
- [API tokens](api/tokens.md)
- [Sites, hostnames and keys](api/sites.md)
- [Workspace and members](api/members.md)
For AI agents, see [Agents and MCP](agents-and-mcp.md).
---
# Accounts and sign-in
People sign in with an email and password; the API answers with a session cookie, `us_session` (HttpOnly, Secure,
SameSite=Lax, 30 days, extended while in use). Programs use [API tokens](tokens.md) instead. Every error is
`application/problem+json` with a `title` that says what to do. The full reference is
`GET /v1/openapi.json` (OpenAPI 3.1, generated from the routes the API serves).
The examples below run, in this order, in the API's test suite. They keep the cookie in `cookies.txt` with curl's
`-c` (save) and `-b` (send).
| Route | What it does |
|---|---|
| `POST /v1/auth/signup` | Create an account and its workspace; emails a confirmation link (24 hours) |
| `POST /v1/auth/verify` | Confirm the email address with the token from that link |
| `POST /v1/auth/verify/resend` | Send a new confirmation link |
| `POST /v1/auth/login` | Sign in; sets the cookie, or asks for a second factor |
| `POST /v1/auth/login/2fa` | The second step: the authenticator code |
| `POST /v1/auth/logout` | End this session |
| `GET /v1/auth/sessions` | Your live sessions |
| `DELETE /v1/auth/sessions/{id}` | End one of them |
| `POST /v1/auth/password/change` | Change your password (keeps this session, ends the others) |
| `POST /v1/auth/password/reset-request` | Email a reset link (1 hour) |
| `POST /v1/auth/password/reset` | Set a new password with that link; signs out everywhere |
| `POST /v1/auth/2fa/enroll` | Start two-factor sign-in (TOTP) |
| `POST /v1/auth/2fa/confirm` | Turn it on with a code |
| `POST /v1/auth/2fa/disable` | Turn it off (password and code) |
| `GET /v1/me` | Who you are: your account, workspaces, role and scopes |
| `GET /v1/openapi.json` | The API reference |
## Create an account
Passwords need at least 10 characters, and one that appears in a known data breach (checked against Have I Been
Pwned without sending the password) is refused. The answer is the same whether or not the email already has an
account.
```sh
curl https://api.usherstats.com/v1/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "plum-orbit-71-lantern", "workspaceName": "Ana Sites"}'
```
The email holds a link to `https://app.usherstats.com/verify?token=...`. The app posts that token here with the password
the account was signed up with (so a link opened by someone who did not sign up confirms nothing); it works once, and
starts no session: sign in afterwards.
```sh
curl https://api.usherstats.com/v1/auth/verify \
-H "Content-Type: application/json" \
--data @- <
Lost the email? Ask for another (always `202`, so it does not reveal who has an account):
```sh
curl https://api.usherstats.com/v1/auth/verify/resend \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com"}'
```
## Sign in
Until the address is confirmed, sign-in answers `403` with type `email-unverified`. After five wrong passwords for
one account from one address, sign-ins to that account from that address wait 30 seconds, doubling with each further
failure up to 15 minutes; meanwhile they get the same `401` as a wrong password, and other addresses are not
affected. The owner gets one email an hour about it. A password reset clears it at once.
```sh
curl https://api.usherstats.com/v1/auth/login -c cookies.txt \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "plum-orbit-71-lantern"}'
```
```sh
curl https://api.usherstats.com/v1/me -b cookies.txt
```
`/v1/me` lists your workspaces. If you belong to more than one, choose the one a request acts on with the
`X-Workspace: ws_...` header (or `?workspace=ws_...`); without it, the oldest membership is used.
## Sessions
```sh
curl https://api.usherstats.com/v1/auth/sessions -b cookies.txt
```
```sh
curl -X POST https://api.usherstats.com/v1/auth/password/change -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"currentPassword": "plum-orbit-71-lantern", "newPassword": "cedar-quartz-58-harbor"}'
```
## Two-factor sign-in
Enrolling returns a secret and an `otpauth://` URI for an authenticator app (show the URI as a QR code). It is not on
until a code from the app is confirmed. The secret is stored encrypted; each code works once.
```sh
curl -X POST https://api.usherstats.com/v1/auth/2fa/enroll -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"password": "cedar-quartz-58-harbor"}'
```
```sh
curl -X POST https://api.usherstats.com/v1/auth/2fa/confirm -b cookies.txt \
-H "Content-Type: application/json" \
--data @- <
Sign out, then sign in again: now the password step returns a `challenge` instead of a cookie, and the code
completes it (five guesses, five minutes).
```sh
curl -X POST https://api.usherstats.com/v1/auth/logout -b cookies.txt -c cookies.txt
```
```sh
curl https://api.usherstats.com/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "cedar-quartz-58-harbor"}'
```
```sh
curl https://api.usherstats.com/v1/auth/login/2fa -c cookies.txt \
-H "Content-Type: application/json" \
--data @- <
```sh
curl -X POST https://api.usherstats.com/v1/auth/2fa/disable -b cookies.txt \
-H "Content-Type: application/json" \
--data @- <
## Forgotten password
Always `202`. The link (`https://app.usherstats.com/reset?token=...`) works once, for one hour. Setting the new
password signs out every session.
```sh
curl https://api.usherstats.com/v1/auth/password/reset-request \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com"}'
```
```sh
curl https://api.usherstats.com/v1/auth/password/reset \
-H "Content-Type: application/json" \
--data @- <
```sh
curl https://api.usherstats.com/v1/me -b cookies.txt
```
Sign in with the new password, and end that session by its id:
```sh
curl https://api.usherstats.com/v1/auth/login -c cookies.txt \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "violet-anchor-93-meadow"}'
```
```sh
curl https://api.usherstats.com/v1/auth/sessions -b cookies.txt
```
```sh
curl -X DELETE https://api.usherstats.com/v1/auth/sessions/$SESSION_ID -b cookies.txt -c cookies.txt
```
## The reference
```sh
curl https://api.usherstats.com/v1/openapi.json
```
## Limits
Sign-up, sign-in, the confirmation and reset emails are rate limited per address (and the emails per recipient);
an API token may make 600 requests a minute. Past a limit the answer is `429` with `Retry-After` in seconds.
---
# API tokens
An API token lets a program -- a script, a CI job, an AI agent -- use the API as a person with the same scopes
could, including making and revoking other tokens when it holds `tokens:write`. Send it as
`Authorization: Bearer us_live_...`. A token belongs to the workspace it was made in; it never needs (or accepts)
a workspace header.
- **Shown once.** The response that creates a token is the only time it is shown; UsherStats keeps a SHA-256 of it.
Lists show the first characters (`prefix`) so you can tell tokens apart.
- **Scopes** (`module:action`): `analytics:read`, `sites:read`, `sites:write`, `bot:read`, `bot:write`,
`search:read`, `search:write`, `members:read`, `members:write`, `tokens:read`, `tokens:write`, `billing:read`,
`billing:write`, `export:read`, and `account:admin` (everything). A call without the scope it needs gets `403`
naming the scope.
- **Site restriction.** `siteIds` limits a token to those sites; every other site is `404` to it, as if it did not
exist. A site-restricted token cannot create sites or manage members.
- **Expiry.** `expiresAt` (ISO 8601) ends it; a revoked or expired token gets `401` saying which.
- **Never wider than its maker.** A token can only be given scopes, sites and a lifetime its creator has. Tokens a
person made are revoked when they leave the workspace, and those exceeding a new role when they are demoted.
| Route | Scope |
|---|---|
| `GET /v1/tokens` | `tokens:read` |
| `POST /v1/tokens` | `tokens:write` |
| `DELETE /v1/tokens/{id}` | `tokens:write` |
The examples assume `$US_TOKEN` holds a token with `account:admin`.
## Create a token
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "nightly report", "scopes": ["sites:read", "analytics:read"], "expiresAt": "2027-01-01T00:00:00Z"}'
```
The response has the token in `token` (`us_live_` and 40 letters and digits) with `"shownOnce": true`. Store it now.
## Use it
```sh
curl https://api.usherstats.com/v1/me \
-H "Authorization: Bearer $NEW_TOKEN"
```
`/v1/me` answers with the token's AuthContext: its workspace, scopes and sites. A call it lacks the scope for:
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $NEW_TOKEN"
```
## List tokens
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $US_TOKEN"
```
## Revoke a token
It stops working at once.
```sh
curl -X DELETE https://api.usherstats.com/v1/tokens/$NEW_TOKEN_ID \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl https://api.usherstats.com/v1/me \
-H "Authorization: Bearer $NEW_TOKEN"
```
## A token that makes tokens
An agent token with `tokens:write` can hand out narrower tokens, but nothing it does not hold itself:
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "agent", "scopes": ["tokens:write", "sites:read"], "expiresAt": "2027-12-31T00:00:00Z"}'
```
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "too wide", "scopes": ["sites:write"], "expiresAt": "2027-12-01T00:00:00Z"}'
```
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "reader", "scopes": ["sites:read"], "expiresAt": "2027-12-01T00:00:00Z"}'
```
---
# Sites, hostnames and keys
A site is what UsherStats measures: a name, a time zone, the hostnames it is served on, and its keys.
- The **public key** (`pk_` and 26 characters) goes in the snippet. It is public by design; collect accepts it only
from the site's hostnames.
- A **server secret** (`sk_live_` and 40 characters) authenticates the server SDK and proxy mode. It is shown once
and stored hashed, like an API token.
Reading needs `sites:read`, changing needs `sites:write`. Another workspace's site, or one outside a token's site
restriction, is `404`. The Free plan has 5 sites, Starter 20, Growth and Business unlimited; past the limit,
creating a site answers `402` naming the plan.
| Route | Scope |
|---|---|
| `GET /v1/sites` | `sites:read` |
| `POST /v1/sites` | `sites:write` (not for site-restricted tokens) |
| `GET /v1/sites/{id}` | `sites:read` |
| `PATCH /v1/sites/{id}` | `sites:write` |
| `DELETE /v1/sites/{id}` | `sites:write` |
| `POST /v1/sites/{id}/hostnames` | `sites:write` |
| `GET /v1/sites/{id}/hostnames/{hostname}` | `sites:read` |
| `POST /v1/sites/{id}/hostnames/{hostname}/verify` | `sites:write` |
| `DELETE /v1/sites/{id}/hostnames/{hostname}` | `sites:write` |
| `GET /v1/sites/{id}/keys` | `sites:read` |
| `POST /v1/sites/{id}/keys` | `sites:write` |
| `POST /v1/sites/{id}/keys/{keyId}/rotate` | `sites:write` |
| `DELETE /v1/sites/{id}/keys/{keyId}` | `sites:write` |
| `GET /v1/sites/{id}/snippet` | `sites:read` |
| `GET /v1/sites/{id}/install-status` | `sites:read` |
| `PATCH /v1/sites/{id}/settings` | `sites:write` |
The examples assume `$US_TOKEN` holds a token with `account:admin`.
## Create a site
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Field notes", "timezone": "Europe/Bucharest", "hostnames": ["fieldnotes.example", "www.fieldnotes.example"]}'
```
The answer holds the site and its first public key.
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl -X PATCH https://api.usherstats.com/v1/sites/$SITE_ID \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Field Notes", "timezone": "America/New_York"}'
```
## Install it
The snippet is a script tag with the public key. Serving it through your own domain (`/_us/`) keeps it working where
third-party analytics hosts are blocked; the answer includes the steps.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/snippet \
-H "Authorization: Bearer $US_TOKEN"
```
```html
```
Once a page with the snippet has been viewed, the install check says so (collect stamps the site at most once a
minute):
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/install-status \
-H "Authorization: Bearer $US_TOKEN"
```
## Hostnames
Bare hostnames only (no scheme, path or port); they are lower-cased. Up to 50 per site.
A hostname may be on sites of more than one workspace: collect only counts a page for a site whose key the page
carries. Proxy mode is stricter: a proxied hostname belongs to exactly one site, and is switched on only after its
ownership is verified (`verifiedAt`), through its Cloudflare custom hostname's validation or a DNS TXT record.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/hostnames \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"hostname": "blog.fieldnotes.example"}'
```
Each hostname carries `verification`: the DNS record that proves the hostname is yours, a TXT record
`_usherstats.` with the value `usherstats-verify=` (the token is this site's own for this hostname).
Publish it at your DNS provider, then ask for the check. A verified hostname (`verifiedAt` set, `verification` null) is
one the bot-protection challenge may send visitors back to, and one delegated Search Console access may read; an
unverified one is still counted by collect. The check is limited to 30 an hour per site.
```sh
curl -X POST https://api.usherstats.com/v1/sites/$SITE_ID/hostnames/blog.fieldnotes.example/verify -H "Authorization: Bearer $US_TOKEN"
```
The answer's `verified` says whether the record was found; when it was not, `message` names the record to publish.
Proxy-mode hostnames need no TXT record of ours: Cloudflare's validation of the custom hostname verifies them.
One hostname: for a proxy-mode hostname, `proxy` is its live status at Cloudflare and the DNS records it still needs;
for any other, `proxy` is null.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/hostnames/blog.fieldnotes.example \
-H "Authorization: Bearer $US_TOKEN"
```
To serve a hostname through [proxy mode](../proxy-mode.md), add it with `"proxy": true` and the `origin` its
requests are forwarded to. The answer's `proxy.records` are the DNS records to set (a CNAME to the proxy; the TXT
records let it go live before the DNS moves). Proxy hostnames count against the plan's allowance: paid plans are billed for each one past it, Free is
refused with a 402; and
a hostname another site already proxies is a 409.
```json
{ "hostname": "shop.fieldnotes.example", "proxy": true, "origin": "https://origin.fieldnotes.example" }
```
Removing a proxy-mode hostname removes it at Cloudflare as well.
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/hostnames/blog.fieldnotes.example \
-H "Authorization: Bearer $US_TOKEN"
```
## Keys
A server secret, for the server SDK or proxy mode. `value` is in this answer only:
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/keys \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "secret"}'
```
Listing shows public keys in full and secrets by prefix:
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/keys \
-H "Authorization: Bearer $US_TOKEN"
```
Rotating makes a new key of the same kind and ends the old one at once, so deploy the new secret right after:
```sh
curl -X POST https://api.usherstats.com/v1/sites/$SITE_ID/keys/$SECRET_ID/rotate \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/keys/$NEW_SECRET_ID \
-H "Authorization: Bearer $US_TOKEN"
```
## Settings
Four lists; a PATCH replaces the ones it names and keeps the rest.
- `internalNetworks`: CIDRs (IPv4 or IPv6) whose traffic is your own, counted apart from readers.
- `botWindows`: time windows (`from`, `to`, optional `country`, `network`, `note`) whose traffic is filed as a bot
fleet after the fact.
- `goals`: conversions, by event name (`"type": "event"`) or path (`"type": "path"`).
- `experiments`: A/B tests by `key`, with at least two `variants` and a `status` of draft, running or stopped.
```sh
curl -X PATCH https://api.usherstats.com/v1/sites/$SITE_ID/settings \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"internalNetworks": ["203.0.113.0/24", "2001:db8::/32"],
"botWindows": [{"from": "2026-09-30T03:00:00Z", "to": "2026-09-30T03:10:00Z", "country": "CN", "note": "one fleet"}],
"goals": [{"name": "Newsletter", "type": "event", "match": "subscribe"}],
"experiments": [{"key": "hero-copy", "variants": ["control", "short"], "status": "running"}]
}'
```
A value that does not fit is `400` naming the field, such as `internalNetworks[0]`:
```sh
curl -X PATCH https://api.usherstats.com/v1/sites/$SITE_ID/settings \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"internalNetworks": ["10.0.0.0/33"]}'
```
## Delete a site
Its keys stop working at once.
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID \
-H "Authorization: Bearer $US_TOKEN"
```
---
# Workspace and members
A workspace holds sites and the people who work on them. Each person has a role, and the role decides their scopes:
| Role | Scopes |
|---|---|
| owner | everything (`account:admin`) |
| admin | everything except `billing:write` and deleting the account |
| member | every `:read` scope, plus `sites:write`, `bot:write`, `search:write` |
| viewer | `analytics:read`, `sites:read`, `bot:read`, `search:read` |
Only an owner (or a token with `account:admin`) makes, changes or removes an owner, and the last owner can neither be
demoted nor leave. Removing someone revokes every API token they made in the workspace; demoting them revokes those
that hold scopes the new role lacks. Plans limit members, counting pending invitations: Free 2, Starter 5, Growth
15, Business unlimited (`402` past the limit).
| Route | Scope |
|---|---|
| `GET /v1/workspace` | any member or token of the workspace |
| `PATCH /v1/workspace` | `members:write` |
| `GET /v1/members` | `members:read` |
| `POST /v1/members/invites` | `members:write` |
| `DELETE /v1/members/invites/{id}` | `members:write` |
| `POST /v1/invites/accept` | the invited person, signed in |
| `PATCH /v1/members/{userId}` | `members:write` |
| `DELETE /v1/members/{userId}` | `members:write`, or your own id to leave |
Changes to members are not available to site-restricted tokens. The examples assume `$US_TOKEN` holds a token with
`account:admin`, and that Sam (`sam@example.com`) has an account and is signed in with `sam-cookies.txt`.
## The workspace
```sh
curl https://api.usherstats.com/v1/workspace \
-H "Authorization: Bearer $US_TOKEN"
```
The answer includes the plan, its `limits` (`null` is unlimited) and current `usage`.
```sh
curl -X PATCH https://api.usherstats.com/v1/workspace \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Field Notes Ltd"}'
```
## Invite someone
The invitation email links to `https://app.usherstats.com/invite?token=...`; it expires in 7 days. Withdraw one
that is no longer wanted:
```sh
curl https://api.usherstats.com/v1/members/invites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "alex@example.com", "role": "viewer"}'
```
```sh
curl -X DELETE https://api.usherstats.com/v1/members/invites/$ALEX_INVITE \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl https://api.usherstats.com/v1/members/invites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "sam@example.com", "role": "member"}'
```
Sam accepts while signed in with the invited address (an invitation for another address is `404`):
```sh
curl https://api.usherstats.com/v1/invites/accept -b sam-cookies.txt \
-H "Content-Type: application/json" \
--data @- <
Sam now belongs to two workspaces, and picks this one per request with `X-Workspace`:
```sh
curl https://api.usherstats.com/v1/me -b sam-cookies.txt \
-H "X-Workspace: $WORKSPACE_ID"
```
## Members
```sh
curl https://api.usherstats.com/v1/members \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl -X PATCH https://api.usherstats.com/v1/members/$SAM_ID \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "viewer"}'
```
The last owner cannot step down:
```sh
curl -X PATCH https://api.usherstats.com/v1/members/$OWNER_ID \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
```
Remove a member (or, with your own user id, leave):
```sh
curl -X DELETE https://api.usherstats.com/v1/members/$SAM_ID \
-H "Authorization: Bearer $US_TOKEN"
```
---
---
title: Agents and MCP
description: Give an AI agent access to UsherStats through the API, the OpenAPI description, the MCP server and these docs as Markdown.
nav: Agents & MCP
---
# Agents and MCP
UsherStats treats AI agents as first-class users. An agent can do anything a person with the same scopes can, through
the same API, and these docs are written to be read by agents as well as people.
## Give the agent a token
Create an [API token](api-and-tokens.md#tokens) for the agent, and only for it:
- the **fewest scopes** the job needs: `analytics:read` alone for an agent that reports on traffic; add `sites:write` or
`bot:write` only for one that should change settings;
- **limited to the sites** it works on;
- with an **expiry**, if the job is temporary.
Every change an agent makes is in the workspace's audit log under its token, and revoking the token stops it at once.
## The MCP server
UsherStats provides a Model Context Protocol (MCP) server, so an MCP-capable assistant or agent can query your
analytics and manage your sites as tools, using the same token and the same scopes as the API. Its address, the tools
it offers and setup instructions for common clients are added to this page when it launches.
## The API directly
Agents that call HTTP APIs can use the OpenAPI 3.1 description at
`https://api.usherstats.com/v1/openapi.json`: it lists every route, its parameters, its scopes and its responses.
Errors are `application/problem+json` with a `title` written to be acted on.
## Docs for agents
- Every page of these docs is also served as Markdown: add `.md` to its address.
- [llms.txt](https://docs.usherstats.com/llms.txt) lists every page with a one-line summary.
- [llms-full.txt](https://docs.usherstats.com/llms-full.txt) is every page in one file.
## Good practice
- Read before you write: have the agent show you a change (for example, new bot protection rules in `log` mode) before
it applies it.
- Keep tokens out of prompts and logs; give them to the agent's runtime as a secret.
---
# Analytics
Every figure in the dashboard comes from one query catalogue, and the API exposes it as it is: you name a **metric
set**, a **dimension** and a few numbers, and UsherStats writes the query. There is no SQL to send and nothing to
escape. The dashboard reads through these same routes.
All of them need the `analytics:read` scope. A site in another workspace, or outside a token's site restriction, is
`404`.
| Route | Scope |
|---|---|
| `GET /v1/stats/catalogue` | `analytics:read` |
| `GET /v1/sites/{id}/stats` | `analytics:read` |
| `POST /v1/sites/{id}/stats/query` | `analytics:read` |
| `POST /v1/sites/{id}/stats/batch` | `analytics:read` |
| `GET /v1/sites/{id}/summary` | `analytics:read` |
| `GET /v1/sites/{id}/top/{dimension}` | `analytics:read` |
| `POST /v1/sites/{id}/internal-link` | `sites:write` |
The examples assume `$US_TOKEN` holds a token with `account:admin`. First, a site to read:
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Field notes", "hostnames": ["fieldnotes.example"]}'
```
## The catalogue
Four metric sets: `pageviews` (confirmed pageviews in a browser), `sessions`, `requests` (everything your server or
proxy answered, crawlers included) and `events`. Each lists its lanes (`human`, `bot`, `internal`), the dimensions
it can be broken down by, the filters it takes and the counts it can sort by.
```sh
curl https://api.usherstats.com/v1/stats/catalogue \
-H "Authorization: Bearer $US_TOKEN"
```
## One query
A spec is `metrics` and `by`, plus any of:
- `lane`: `human` (People), `bot` (Crawlers) or `internal` (your own team). Each metric set has a default.
- `days` (calendar days ending today; `1` is today so far, default `7`), or `from` and `to` (YYYY-MM-DD).
- `compare`: `true` adds `previous`, the period before (a single day is compared with the same weekday a week
earlier), or says why it cannot be compared yet.
- `where`: filters, such as `{"source": "google"}`. In a query string, `where.source=google`.
- `sort`: which count a table is ranked by, such as `sessions` or `pageviews`.
Analytics Engine keeps 90 days; past that, UsherStats answers from the daily rollups each site keeps for as long as
the account exists. Hourly series and page-to-page pairs are not rolled up, so they answer only within 90 days.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/stats?metrics=pageviews&by=source&days=30&sort=sessions" \
-H "Authorization: Bearer $US_TOKEN"
```
The same as JSON (the form an agent's tool call takes):
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/stats/query \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"metrics": "requests", "by": "bot", "lane": "bot", "days": 7, "compare": true}'
```
A name that is not on the catalogue's lists is `400`, and `field` says which:
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/stats?metrics=pageviews&by=blob3" \
-H "Authorization: Bearer $US_TOKEN"
```
## Many queries at once
Up to 40 named specs in one call. A spec that is refused answers `{"error": {"status": 400, "title": ...}}` under
its name; the others still answer.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/stats/batch \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"queries": {"byDay": {"metrics": "pageviews", "by": "day", "days": 30}, "crawlers": {"metrics": "requests", "by": "category"}}}'
```
## The People summary
The People boxes as numbers: sessions, pageviews, from search, average session, bounce rate, single-page visits and
pages per session, each with the previous period's figure and the change. A change carries a `tone`: `good` or `bad`
where one direction is better (a lower bounce rate is better), `flat` when nothing moved.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/summary?days=7" \
-H "Authorization: Bearer $US_TOKEN"
```
## Top tables
`source`, `medium`, `referrer`, `campaign`, `page`, `page_type`, `country` and `device` count People sessions (where
each began) or, with `count=pageviews`, pageviews. `bot`, `category`, `kind`, `network`, `path`, `status`, `cache` and
`decision` count requests; `event` counts events.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/top/page?days=30&count=pageviews&limit=20" \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/top/bot?days=7" \
-H "Authorization: Bearer $US_TOKEN"
```
## Marking your own browser
Your team's visits belong in Internal, not People. Tools mark themselves with a signed header; a browser is marked
by opening a link on your site's own first-party path (`/_us/`, which the Worker SDK and the first-party proxy set
up). The link works for ten minutes, once per browser you open it in, and the mark lasts 90 days. `POST /v1/sites/{id}/internal-link/rotate` replaces the site's internal key, so every
browser marked before stops counting as yours (mark them again).
The link is made only for a hostname the site has verified (its TXT record, see [sites](sites.md)) or serves in proxy
mode: it sets a cookie on that domain. Verify it first:
```sh
curl -X POST https://api.usherstats.com/v1/sites/$SITE_ID/hostnames/fieldnotes.example/verify \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/internal-link \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"hostname": "fieldnotes.example"}'
```
See also [goals and experiments](goals.md), [bot protection](bot.md), [search](search.md) and
[export](export.md).
---
# Bot protection settings
A site's bot protection is a mode, what to do with declared bots no rule names, and a list of rules; how the gate uses
them is in [Bot protection](../bot-protection.md). Reading needs `bot:read`, changing `bot:write`. What the gate
decided also needs `analytics:read`.
| Route | Scope |
|---|---|
| `GET /v1/sites/{id}/bot` | `bot:read` |
| `PUT /v1/sites/{id}/bot` | `bot:write` |
| `GET /v1/sites/{id}/bot/decisions` | `bot:read` |
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Shop", "hostnames": ["shop.example"]}'
```
A new site's protection is off:
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/bot \
-H "Authorization: Bearer $US_TOKEN"
```
## Settings
- `mode`: `off`, `log` (decide every request and record what it would have done, while serving everyone), `challenge`
or `block`. Start in `log`.
- `bots`: `allow` (the default), `challenge` or `deny`, for a declared bot no rule names.
- `rules`: checked in order; the first that matches decides. Each has an `action` (`allow`, `challenge` or `deny`), an
optional `label`, and a `match` naming at least one of `bot`, `category`, `ai`, `kind`, `verified`, `asn`,
`country` and `path` (a prefix). Categories are `search-engine`, `ai-search`, `ai-assistant`, `ai-training`,
`preview`, `seo`, `monitor`, `archiver`, `tool` and `other`. Every field named must hold. A rule applies to a
verified search engine only when it names it, so a country or network rule cannot take your site out of search.
`PUT` replaces all three:
```sh
curl -X PUT https://api.usherstats.com/v1/sites/$SITE_ID/bot \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode": "log", "bots": "allow", "rules": [{"action": "deny", "label": "training crawlers", "match": {"category": "ai-training"}}, {"action": "challenge", "match": {"path": "/checkout", "asn": [16276]}}]}'
```
A field it cannot accept is `400`, with `field` naming it, such as `rules[0].match`:
```sh
curl -X PUT https://api.usherstats.com/v1/sites/$SITE_ID/bot \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode": "log", "rules": [{"action": "deny", "match": {}}]}'
```
## What it decided
Every request but your own team's, files and refusals included, as `action` and `reason`, by crawler category and
name, with totals per action. In `log` mode the reason reads `log: would challenge: ...`.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/bot/decisions?days=7" \
-H "Authorization: Bearer $US_TOKEN"
```
---
# Export
Everything UsherStats keeps for a site, on every plan; what each part holds is in
[Exporting your data](../data-export.md). An export needs the `export:read` scope.
| Route | Scope |
|---|---|
| `POST /v1/sites/{id}/export` | `export:read` |
| `GET /v1/downloads/{token}` | none: the link is the credential |
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Shop", "hostnames": ["shop.example"]}'
```
## Export a site
The answer lists five files, each with a download link that works for an hour:
- `settings.json`: the site as configured, with its hostnames, goals, experiments and bot protection rules;
- `site-store.ndjson`: the daily totals, journeys, search weeks and settings UsherStats keeps per site, one JSON
object per line, in the form a site can be loaded back from. Credentials (search keys, server secrets) are never
exported: connect them again after a move;
- `rollups.ndjson`: the daily totals alone;
- `archive.json`: the site's raw records, one gzipped NDJSON object per site, kind and hour, each with its own link;
- `manifest.json`: what is where, with counts.
`from` and `to` (YYYY-MM-DD) limit the raw records listed; without them, all of them are.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/export \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "2026-09-01", "to": "2026-09-30"}'
```
## Download
A link needs no token: it names one file and cannot be changed into another. After an hour it answers `410`; export
again for new links.
```sh
curl "$DOWNLOAD_URL"
```
A link that was not made by an export is `404`:
```sh
curl https://api.usherstats.com/v1/downloads/not-a-link
```
---
# Goals, experiments and behaviour
Goals and A/B tests are part of a site's settings; their results come from the same data as the dashboard's
Behaviour view. How to send events and mark experiments in a page is in
[Goals and A/B tests](../goals-and-experiments.md).
Reading the lists needs `sites:read`, changing them `sites:write`, and results `analytics:read`.
| Route | Scope |
|---|---|
| `GET /v1/sites/{id}/goals` | `sites:read` |
| `POST /v1/sites/{id}/goals` | `sites:write` |
| `DELETE /v1/sites/{id}/goals/{goal}` | `sites:write` |
| `GET /v1/sites/{id}/goals/results` | `analytics:read` |
| `GET /v1/sites/{id}/experiments` | `sites:read` |
| `POST /v1/sites/{id}/experiments` | `sites:write` |
| `PATCH /v1/sites/{id}/experiments/{key}` | `sites:write` |
| `DELETE /v1/sites/{id}/experiments/{key}` | `sites:write` |
| `GET /v1/sites/{id}/experiments/{key}/results` | `analytics:read` |
| `GET /v1/sites/{id}/journeys` | `analytics:read` |
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Shop", "hostnames": ["shop.example"]}'
```
## Goals
A goal is an event, by name (`"type": "event"`), or a page (`"type": "path"`; a trailing `*` matches every path
that starts with the rest). Its `id`, made from its name, is what results use.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/goals \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Signup", "type": "event", "match": "signup"}'
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/goals \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Pricing page", "type": "path", "match": "/pricing*"}'
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/goals \
-H "Authorization: Bearer $US_TOKEN"
```
Results count each goal's completions (the events sent, or the page's pageviews), and for an event goal the People
sessions that reached it, out of all People visits:
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/goals/results?days=30" \
-H "Authorization: Bearer $US_TOKEN"
```
`DELETE` removes one by its id. A goal reads the events already stored, so one added today counts last week's
sessions too. To replace every goal at once, `PATCH /v1/sites/{id}/settings` with a `goals` list.
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/goals/pricing-page -H "Authorization: Bearer $US_TOKEN"
```
## Experiments
An experiment's `key` is the `data-exp` name in your page. It needs at least two variants.
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/experiments \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"key": "hero-headline", "name": "Hero headline", "variants": ["a", "b"], "status": "running"}'
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/experiments \
-H "Authorization: Bearer $US_TOKEN"
```
Results, variant by variant: the People sessions shown it, how many reached the goal (`?goal=`, a goal id; the first
event goal by default), the rate, the events sent from it, and `p`, a two-proportion test between the two largest
variants. A variant seen by fewer than five sessions is listed without its figures, and `p` waits until both of the
two largest have five.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/experiments/hero-headline/results?days=30&goal=signup" \
-H "Authorization: Bearer $US_TOKEN"
```
```sh
curl -X PATCH https://api.usherstats.com/v1/sites/$SITE_ID/experiments/hero-headline \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "stopped"}'
```
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/experiments -H "Authorization: Bearer $US_TOKEN" -H "Content-Type: application/json" -d '{"key": "pricing-table", "variants": ["control", "annual-first"], "status": "draft"}'
```
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/experiments/pricing-table \
-H "Authorization: Bearer $US_TOKEN"
```
## Behaviour
The Behaviour view's analysis: visits and goal rates, a Sankey of how sessions unfold (`?anchor=` centres it on one
step), the common paths with their lift on a goal, and every A/B test. `?zoom=` is `page`, `section` (the default) or
`element`; `?who=` is `human` (the default) or `internal`, your own team's sessions, for QA. A group of fewer than five
sessions is never named.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/journeys?days=7&zoom=page" \
-H "Authorization: Bearer $US_TOKEN"
```
---
# Search connections
Google Search Console and Bing Webmaster Tools, connected with access you give and can take back; the setup on
Google's and Bing's side is in [Search Console and Bing](../search-console-and-bing.md). Credentials are checked,
encrypted, and used only by the daily pull of your site's own figures; no route shows a key again. Reading needs
`search:read`, connecting `search:write`.
| Route | Scope |
|---|---|
| `GET /v1/sites/{id}/search` | `search:read` |
| `PUT /v1/sites/{id}/search/google` | `search:write` |
| `PUT /v1/sites/{id}/search/bing` | `search:write` |
| `DELETE /v1/sites/{id}/search/google` | `search:write` |
| `DELETE /v1/sites/{id}/search/bing` | `search:write` |
| `GET /v1/sites/{id}/search/weeks` | `search:read` |
```sh
curl https://api.usherstats.com/v1/sites \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Shop", "hostnames": ["shop.example"]}'
```
What is connected, and `serviceAccount`, the address to add to your Search Console property for delegated access:
```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID/search \
-H "Authorization: Bearer $US_TOKEN"
```
## Google
Delegated: add the `serviceAccount` address to the property (Restricted permission), then connect it. Delegated
access reads with UsherStats' own account, which every customer's property shares, so it is accepted only for a
property whose host is a **verified** hostname of the site (`sc-domain:shop.example` needs `shop.example`; a URL-prefix
property needs its own hostname). Before that, it is `400` with `"field": "property"`:
```sh
curl -X PUT https://api.usherstats.com/v1/sites/$SITE_ID/search/google \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode": "delegated", "property": "sc-domain:shop.example"}'
```
Or with your own service account: `"mode": "service_account"` and the key file's JSON as `"key"` (text or an
object). It reads only what you gave that account, so it needs no verified hostname. A file that is not a
service-account key is `400` with `"field": "key"`:
```sh
curl -X PUT https://api.usherstats.com/v1/sites/$SITE_ID/search/google \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode": "service_account", "property": "sc-domain:shop.example", "key": "{\"type\": \"authorized_user\"}"}'
```
## Bing
```sh
curl -X PUT https://api.usherstats.com/v1/sites/$SITE_ID/search/bing \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"apiKey": "your-bing-api-key", "siteUrl": "https://shop.example/"}'
```
## The figures
One row per week (starting Monday) and source: clicks, impressions, click-through rate, average position, how many
pages and queries were shown, and Bing's inbound links. The current week fills in day by day. A figure that could not
be collected is `null`; a zero is a measurement.
```sh
curl "https://api.usherstats.com/v1/sites/$SITE_ID/search/weeks?weeks=26" \
-H "Authorization: Bearer $US_TOKEN"
```
## Disconnecting
The credentials are deleted; the weeks already collected stay.
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/search/bing \
-H "Authorization: Bearer $US_TOKEN"
```
Disconnecting a source that is not connected is `404`:
```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/search/google \
-H "Authorization: Bearer $US_TOKEN"
```
---
---
title: Exporting your data
description: Export everything UsherStats holds for your sites, raw records and daily totals, from the dashboard or the API.
nav: Data export
---
# Exporting your data
Your data is yours. UsherStats keeps it for as long as your account exists and lets you take all of it out at any
time, on every plan.
## What you can export
- **Raw records**, as UsherStats keeps them:
- **pageviews**: each confirmed pageview, with its page, source, country, device, scroll depth and time on page;
- **events**: each event and goal, with its name, label and value, and A/B exposures;
- **requests**: each request your server or proxy recorded, with the crawler or browser it came from and what bot
protection decided.
- **Daily totals** behind the dashboards.
- **Settings**: sites, hostnames, goals, experiments and bot protection rules.
Raw records are newline-delimited JSON, compressed with gzip, one file per site, kind and hour: the same rows the
archive keeps, so an export is complete rather than a summary.
## How
- In the dashboard: **Settings → Export**, choose the sites and the date range.
- Through the API, with a token that has the `export:read` scope. The routes are in the API reference
(`GET /v1/openapi.json`); see [API and tokens](api-and-tokens.md).
## Limits
- An export answers with download links that work for an hour, and only while whoever made the export can still
export the site: removing a member, revoking a token or deleting the site ends its links.
- The exported files are kept for a day, then removed; export again for a fresh copy.
- A site can be exported 5 times an hour, and a workspace 20 times an hour.
- A date range of up to a month lists exactly its days of raw records; a longer one lists up to 20,000 files and says
when it stopped short, so take a long history a range at a time.
## Deleting data
A site's data can be deleted, or given a retention period after which older records are removed, in the site's
settings. Closing your account deletes all of its sites' data. Export first if you want to keep a copy.
---
---
description: What UsherStats records about your visitors and what it does not, cookies, Global Privacy Control, GDPR roles, and what to tell your readers.
nav: Privacy & compliance
---
# Privacy and compliance
UsherStats is built to measure a site without following the people who read it.
## What is recorded
For each confirmed pageview: the page's path; the `utm_source`, `utm_medium` and `utm_campaign` tags (and no other part
of the address); the referring site's hostname (not its full address); the browser window's width, language and time
zone; scroll depth, time on page and whether the reader interacted; and a page counter for the tab. From the request,
UsherStats derives the country, the network operator, a device class and a summary of the connection's encryption
settings.
With the Worker SDK or proxy mode, each request your server answers is recorded too: its path, status, and the kind of
client that made it.
## What is not
- **No cookies for analytics**, and no identifier that follows a reader from one site to another, or from one day to
the next. The page counter lives in the tab's session storage, which the browser clears when the tab closes.
- **No IP addresses and no full browser identification strings** are stored. They are used while a request is handled,
to classify it, and then dropped.
- **No names, email addresses or form contents.** Never put them in event names or labels either.
- **Global Privacy Control**: browsers that send it are not recorded at all.
Two cookies can exist on your domain, both first-party and both outside analytics:
- `us_pass`, set only on a visitor who has just completed a [bot protection](bot-protection.md) check, so they are not
asked again for a day;
- the internal-traffic cookie, set only in your own team members' browsers when they mark them as internal (see
[Concepts](concepts.md#internal-traffic)).
## GDPR roles
For your visitors' data, you are the **controller** and UsherStats is your **processor**. Our
[data processing agreement](https://usherstats.com/dpa) sets out the processor terms (GDPR Article 28) and covers
transfers with the Standard Contractual Clauses. The companies we use are on the
[subprocessors](https://usherstats.com/subprocessors) page, and the [privacy policy](https://usherstats.com/privacy)
describes everything in full.
## What to put in your own privacy policy
You can describe UsherStats along these lines, adjusted to how you use it:
> We use UsherStats to measure how our site is used. It sets no cookies for analytics, stores no personal data and no
> IP addresses, and does not follow you across sites or days. It records the page you visited, the site that referred
> you, campaign tags, your browser's language, time zone and window size, your country and network, and how long you
> stayed. Browsers that send Global Privacy Control are not recorded. UsherStats processes this data on our behalf.
If you use bot protection, add that requests are checked for automated traffic and some visitors may be asked to
complete a short check, which sets a cookie for a day. Whether you need consent for analytics depends on where you and
your readers are and how you use the data; this page is not legal advice.
## Data retention and deletion
Data is kept for as long as your account exists unless you delete it or set a retention period for a site. See
[Exporting your data](data-export.md).
## Contact
Privacy questions: privacy@usherstats.com.
---
---
description: UsherStats plans, what counts towards them, overage, and how billing works.
nav: Billing & plans
---
# Billing and plans
Every feature is on every plan, Free included. Plans differ only by volume.
| | Free | Starter | Growth | Business |
|---|---|---|---|---|
| Price (monthly / yearly) | $0 | $9 / $90 | $29 / $290 | $99 / $990 |
| Human pageviews / month | 100k | 250k | 1M | 5M |
| Server requests / month (server SDK + proxy) | 1M | 5M | 20M | 75M |
| Sites | 5 | 20 | unlimited | unlimited |
| Proxy-mode hostnames | 1 | 3 | 10 | 50 |
| Team members | 2 | 5 | 15 | unlimited |
| Every feature (analytics, crawler/AI analytics, journeys, A/B, bot protection, Search Console, API, MCP) | yes | yes | yes | yes |
| Data kept | forever | forever | forever | forever |
| Support | docs + community | email | email, 2 business days | priority email |
## What counts
- **Human pageviews**: pageviews counted under People. Crawlers, hosting-network visits and your own team's visits do
not count against this allowance.
- **Server requests**: requests recorded by the [Worker SDK](installing.md#the-worker-sdk) or
[proxy mode](proxy-mode.md), people and bots alike.
- Allowances are per workspace, across all of its sites, and reset at the start of each billing month.
## Going over
- Paid plans: **$10 per extra 1M pageviews**, **$1 per extra 1M server requests**, and **$1 per extra proxy hostname
per month**, billed at the end of the month. Nothing is ever dropped for volume on a paid plan.
- Free never bills. It emails the workspace's owners at 80% and 100% of its allowance. Past 150% of its server
requests, server-request logging pauses until the month turns; pageviews keep counting.
## Paying
Plans are paid by card through Stripe, monthly or yearly (a year costs ten months). Change or cancel your plan, update
your card and download invoices under **Settings → Billing**; owners have the `billing:write` scope needed to change
it. A cancelled plan runs to the end of the period you paid for; your data stays.
---
---
description: Fixes for missing pageviews, missing crawler data, real people being challenged, and empty Search data.
---
# Troubleshooting
## No pageviews arrive
Check, in order:
1. **The hostname.** UsherStats accepts reports only from pages on the site's hostnames. If your site answers on both
`example.com` and `www.example.com`, add both in the site's settings.
2. **The key.** The snippet's `data-site` must be the site's public key exactly: `pk_` and 26 letters and digits. A
malformed key makes the script do nothing at all.
3. **Your browser.** Browsers that send Global Privacy Control (Brave and DuckDuckGo by default, and some extensions)
are not recorded. Test with another.
4. **Leave the page.** A page reports when it is hidden or closed, not when it loads. Switch tabs or close it, then look.
5. **Content Security Policy.** If your site sends one, allow `https://collect.usherstats.com` in `script-src` and
`connect-src`, or use a [first-party path](installing.md#a-first-party-path).
6. **Blockers.** Some blocking extensions drop third-party analytics. A first-party path avoids that.
## My visits show under Crawlers or Internal, not People
That is usually right:
- **Internal**: your browser is marked as internal, or you are on a network listed in the site's internal networks.
- **Crawlers**: you are browsing from a hosting network (many VPNs run on cloud servers), your computer reports its
time zone as `UTC`, or the session went past twenty pages. See [Concepts](concepts.md#what-counts-as-a-pageview).
## People is empty behind my own proxy
A first-party path on your own server must send your server secret and the visitor's IP address, country and network
(AS number and name). Without them UsherStats sees your server, a hosting network, and counts every reader as
automated. See [A first-party path](installing.md#a-first-party-path), or use the snippet directly.
## A single-page app shows one pageview per visit
The snippet reports one pageview per full page load. Navigations inside a single-page app are not reported as
separate pageviews yet.
## The Crawlers view is empty
Crawlers do not run scripts, so the snippet cannot see them. Install the [Worker SDK](installing.md#the-worker-sdk) or
use [proxy mode](proxy-mode.md). If you have and it is still empty, check that the Worker's `USHERSTATS_SECRET` is the
site's current server secret. On Free, server-request logging pauses past 150% of the month's allowance; see
[Billing and plans](billing-and-plans.md).
## Real people are being challenged
Switch the site to `log` mode, read the **Bot protection** view to see which rule or signal is responsible, and adjust
your rules. See [Bot protection](bot-protection.md).
## Search data is missing
- The service account (yours or ours) must be a user of the Search Console property, with at least Restricted access.
- The property must match your site: a domain property covers every hostname; a URL-prefix property only its prefix.
- Search engines publish data a few days late, so the most recent days fill in later.
See [Search Console and Bing setup](search-console-and-bing.md).
## Still stuck
Write to support@usherstats.com with your site's name and what you expected to see.
---
# UsherStats for AI agents (MCP)
UsherStats is a remote [Model Context Protocol](https://modelcontextprotocol.io) server. Point any MCP client --
Claude Code, Claude Desktop, Cursor, or your own agent -- at
```text
https://api.usherstats.com/mcp
```
with an API token, and the agent can do what the token can: list and create sites, add hostnames, read the install
snippet and status, manage keys, settings, tokens and team members. Every tool *is* an API route, called as your
token, so scopes, site restrictions and plan limits apply exactly as they do over HTTP. Analytics tools (stats, top
pages and sources, crawlers, journeys, experiments, bot rules, search data) appear here as soon as those API routes
ship, with no change on your side.
- **Endpoint:** `POST /mcp` (Streamable HTTP, JSON-RPC 2.0, one JSON answer per request, no sessions).
- **Protocol versions:** `2026-07-28` (stateless: no `initialize`; version in each request's `_meta` and the
`MCP-Protocol-Version` header) and `2025-03-26`, `2025-06-18`, `2025-11-25` (opening with `initialize`).
- **Authentication:** `Authorization: Bearer us_live_...` only. Sign-in cookies are not accepted on `/mcp`.
- **The HTTP API** behind the tools is described at
[https://api.usherstats.com/v1/openapi.json](https://api.usherstats.com/v1/openapi.json) (OpenAPI 3.1).
## 1. Make a token for the agent
Give the agent only what it needs. A token can be limited to some sites (`siteIds`) and given an expiry; it is shown
once. For an agent that reports and helps with setup but changes nothing:
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "claude (read only)", "scopes": ["sites:read", "analytics:read", "members:read"], "expiresAt": "2027-06-30T00:00:00Z"}'
```
For an agent that may also set sites up (create sites, add hostnames, change settings), add `sites:write`:
```sh
curl https://api.usherstats.com/v1/tokens \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "claude (setup)", "scopes": ["sites:read", "sites:write", "analytics:read"], "expiresAt": "2027-06-30T00:00:00Z"}'
```
You can also make tokens in the dashboard under **Settings > API tokens**. Revoke one with `DELETE /v1/tokens/{id}`;
it stops working at once.
## 2. Connect your client
Keep the token out of files you commit: each snippet below reads it from the environment variable `US_TOKEN`.
### Claude Code
```sh
claude mcp add --transport http usherstats https://api.usherstats.com/mcp --header "Authorization: Bearer $US_TOKEN"
```
Or share it with a project in `.mcp.json` (Claude Code expands `${US_TOKEN}` from the environment):
```json
{
"mcpServers": {
"usherstats": {
"type": "http",
"url": "https://api.usherstats.com/mcp",
"headers": { "Authorization": "Bearer ${US_TOKEN}" }
}
}
}
```
### Claude Desktop
Claude Desktop starts local servers from `claude_desktop_config.json`; the `mcp-remote` bridge connects it to a remote
server with a header. Put the token in `env`, not in the arguments:
```json
{
"mcpServers": {
"usherstats": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.usherstats.com/mcp", "--header", "Authorization:${US_AUTH}"],
"env": { "US_AUTH": "Bearer us_live_..." }
}
}
}
```
### Cursor
In `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (one project):
```json
{
"mcpServers": {
"usherstats": {
"url": "https://api.usherstats.com/mcp",
"headers": { "Authorization": "Bearer ${env:US_TOKEN}" }
}
}
}
```
### Any other MCP client
Configure a **Streamable HTTP** (sometimes "HTTP" or "remote") server with the URL `https://api.usherstats.com/mcp`
and the request header `Authorization: Bearer `. The server does not use OAuth; a client that only
supports OAuth for remote servers can use the `mcp-remote` bridge as in the Claude Desktop example.
## 3. What to ask
- "Which of my sites have the UsherStats snippet installed, and when did each last send data?"
- "Create a site called Docs for docs.example.com in Europe/Bucharest and give me the snippet to paste."
- "Add shop.example.com as a hostname of my Shop site."
- "Mark 203.0.113.0/24 as our office network on every site so our own visits are not counted."
- "Add a goal named Signup on the path /welcome for the Shop site."
- "Who is on the team, and which invitations are still pending?"
- "List the API tokens nobody has used in the last 30 days."
The agent starts with `get_me` (what the token may do) and `list_sites`; every id it needs comes from a `list_` or
`get_` tool. When a call is refused, the tool error's first line says why and what to do -- for example
`This needs the "sites:write" scope, which this API token does not have` -- so the agent can tell you, rather than
guess.
**Retries.** Every tool that changes something takes an optional `idempotencyKey`. An agent that repeats a call with
the same key within 24 hours (after a timeout, say) gets the first result back instead of creating a second site or
token. The same key works over HTTP as the `Idempotency-Key` header.
**Limits.** A token makes up to 600 calls a minute, and every tool call counts, including each one inside a JSON-RPC
batch (protocol version 2025-03-26 only). A batch carries at most 20 messages.
## Tools
One tool per API route a token can call. The list is generated from the routes, so it is always exactly what the API
serves; `tools/list` returns each tool's input schema (JSON Schema) and, for tools that return data, its output schema.
| Tool | Scope |
|---|---|
| `get_me` | none |
| `get_workspace` | `members:read` |
| `update_workspace` | `members:write` |
| `list_members` | `members:read` |
| `create_member_invite` | `members:write` |
| `delete_member_invite` | `members:write` |
| `update_member` | `members:write` |
| `delete_member` | `members:write` |
| `list_tokens` | `tokens:read` |
| `create_token` | `tokens:write` |
| `delete_token` | `tokens:write` |
| `list_sites` | `sites:read` |
| `create_site` | `sites:write` |
| `get_site` | `sites:read` |
| `update_site` | `sites:write` |
| `delete_site` | `sites:write` |
| `create_site_hostname` | `sites:write` |
| `get_site_hostname` | `sites:read` |
| `verify_site_hostname` | `sites:write` |
| `delete_site_hostname` | `sites:write` |
| `list_site_keys` | `sites:read` |
| `create_site_key` | `sites:write` |
| `rotate_site_key` | `sites:write` |
| `delete_site_key` | `sites:write` |
| `get_site_snippet` | `sites:read` |
| `get_site_install_status` | `sites:read` |
| `update_site_settings` | `sites:write` |
| `get_stats_catalogue` | `analytics:read` |
| `get_site_stats` | `analytics:read` |
| `query_site_stats` | `analytics:read` |
| `batch_site_stats` | `analytics:read` |
| `get_site_summary` | `analytics:read` |
| `get_site_top` | `analytics:read` |
| `list_site_goals` | `sites:read` |
| `create_site_goal` | `sites:write` |
| `delete_site_goal` | `sites:write` |
| `get_site_goal_results` | `analytics:read` |
| `list_site_experiments` | `sites:read` |
| `create_site_experiment` | `sites:write` |
| `update_site_experiment` | `sites:write` |
| `delete_site_experiment` | `sites:write` |
| `get_site_experiment_results` | `analytics:read` |
| `get_site_journeys` | `analytics:read` |
| `get_site_bot` | `bot:read` |
| `update_site_bot` | `bot:write` |
| `get_site_bot_decisions` | `bot:read` |
| `get_site_search` | `search:read` |
| `update_site_search_google` | `search:write` |
| `update_site_search_bing` | `search:write` |
| `delete_site_search_google` | `search:write` |
| `delete_site_search_bing` | `search:write` |
| `get_site_search_weeks` | `search:read` |
| `internal_link_site` | `sites:write` |
| `rotate_site_internal_link` | `sites:write` |
| `export_site` | `export:read` |
| `get_billing` | `billing:read` |
| `checkout_billing` | `billing:write` |
| `portal_billing` | `billing:write` |
| `plan_billing` | `billing:write` |
Not tools: sign-up, sign-in, passwords, sessions and two-factor setup (a person does these; an agent uses a token),
accepting an invitation (it joins a signed-in person to a workspace), and the OpenAPI document (fetch it over HTTP).
## The protocol, by hand
What a client sends, for anyone writing their own. Each request is its own `POST /mcp`. With `2026-07-28`, the
version and client capabilities travel in `params._meta`, and the method (and, for `tools/call`, the tool name) are
mirrored in headers; a header that disagrees with the body is refused with `400` and error `-32020`.
Discover the server: supported versions, capabilities and instructions for the model.
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: server/discover" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
List the tools:
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: $VERSION" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
Call one. The result has the route's answer as JSON text in `content` and as `structuredContent`:
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: create_site" \
-d '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "create_site", "arguments": {"name": "Docs", "hostnames": ["docs.example.com"], "idempotencyKey": "create-docs-1"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: get_site_snippet" \
--data @- <
A refusal is a tool result with `"isError": true` whose text starts with what to do. The read-only token cannot
create sites:
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $READ_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: create_site" \
-d '{"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "create_site", "arguments": {"name": "Nope"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
Without a token, `/mcp` answers `401` (as `application/problem+json`, with `WWW-Authenticate: Bearer`):
```sh
curl https://api.usherstats.com/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 6, "method": "tools/list"}'
```
A client on an earlier protocol version opens with `initialize`; no session id is issued, so every later request
stands alone (send `MCP-Protocol-Version` with the negotiated version):
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 7, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "my-agent", "version": "1.0"}}}'
```
```sh
curl https://api.usherstats.com/mcp \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: $NEGOTIATED" \
-d '{"jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": {"name": "list_sites", "arguments": {}}}'
```
`GET /mcp` and `DELETE /mcp` answer `405`: there is no standalone event stream and no session to end.
---
# Plans and billing
Every plan has every feature: analytics, crawler and AI-bot analytics, journeys, A/B tests, bot protection, Search
Console, the API and MCP. Plans differ only by volume, and data is kept forever on all of them.
| | Free | Starter | Growth | Business |
|---|---|---|---|---|
| Price (monthly / yearly) | $0 | $9 / $90 | $29 / $290 | $99 / $990 |
| Human pageviews / month | 100k | 250k | 1M | 5M |
| Server requests / month | 1M | 5M | 20M | 75M |
| Sites | 5 | 20 | unlimited | unlimited |
| Proxy-mode hostnames | 1 | 3 | 10 | 50 |
| Team members | 2 | 5 | 15 | unlimited |
Months are calendar months in UTC. Paid subscriptions renew on the 1st, so a billing period is the month that usage
is counted in; the first payment covers the rest of the current month, pro rata.
## Above the allowance
On a paid plan nothing is dropped. Usage above the allowance is billed at the end of the period, pro rata:
- **$10 per extra 1M pageviews** (one cent per 1,000),
- **$1 per extra 1M server requests** (one cent per 10,000),
- **$1 per extra proxy hostname per month**, for each month it exists.
Worked examples, for one month:
| Plan | Pageviews | Server requests | Proxy hostnames | Overage |
|---|---|---|---|---|
| Starter | 250,000 | 5,000,000 | 3 | $0.00 |
| Starter | 400,000 | 5,000,000 | 3 | $1.50 |
| Growth | 1,500,000 | 22,000,000 | 10 | $7.00 |
| Business | 5,000,000 | 80,000,000 | 52 | $7.00 |
| Free | 900,000 | 3,000,000 | 1 | $0.00 |
**Free never bills.** The owners get an email at 80% and at 100% of an allowance. Pageviews keep counting past 100%;
past 150% of its server requests, Free stops logging server requests until the month turns. Paid plans get the 80%
and 100% emails too, as a heads-up.
## Your plan and usage
`GET /v1/billing` (scope `billing:read`, which owners and admins have) answers the plan, the subscription's status,
this month's usage, the overage so far in cents, and a projection to the end of the month.
```sh
curl https://api.usherstats.com/v1/billing \
-H "Authorization: Bearer $US_TOKEN"
```
## Subscribe
Paying is the owner's: it needs `billing:write`, which only owners have (and tokens they make with it). Tokens
restricted to some sites cannot change billing.
`POST /v1/billing/checkout` answers the URL of a Stripe Checkout page for the plan, monthly or yearly. The plan
changes when Stripe confirms the payment, not before.
```sh
curl https://api.usherstats.com/v1/billing/checkout \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan": "growth", "interval": "month"}'
```
Until the payment is confirmed there is no subscription to change, so a plan change is refused:
```sh
curl https://api.usherstats.com/v1/billing/plan \
-H "Authorization: Bearer $US_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan": "starter"}'
```
## Change plan, update the card, cancel
`POST /v1/billing/plan` changes the plan of a subscription:
- an **upgrade** applies once the difference for the rest of the period, invoiced immediately, is paid; if the card
is declined the answer is `402` and the plan stays as it was;
- a **downgrade** applies when the period you paid for ends; until then you keep the larger plan;
- `{"plan": "free"}` **cancels** at the end of the period. Nothing is deleted: the workspace moves to Free and keeps
its data. Choosing a plan again before the period ends withdraws the cancellation.
Switching between monthly and yearly billing in place is not available yet: cancel, and subscribe again when the
period ends.
`POST /v1/billing/portal` answers a link to Stripe's customer portal, where you update the card, download invoices
and receipts, or cancel at the end of the period. It needs a billing account, which the first subscription creates:
```sh
curl -X POST https://api.usherstats.com/v1/billing/portal \
-H "Authorization: Bearer $US_TOKEN"
```
## When a payment fails
The owners get an email with a link to pay the invoice, and nothing changes for 7 days. If the invoice is still
unpaid then, the workspace moves to Free (data kept, collection continues within Free's allowances) and the owners
are told. Paying the invoice later restores the plan.
## Stripe's webhook
`POST /v1/billing/webhook` is for Stripe only. It accepts a request only with a valid `Stripe-Signature` made in the
last 5 minutes, and applies each event once:
```sh
curl https://api.usherstats.com/v1/billing/webhook \
-H "Content-Type: application/json" \
-d '{"id": "evt_1", "type": "customer.subscription.deleted"}'
```
| Route | Scope |
|---|---|
| `GET /v1/billing` | `billing:read` |
| `POST /v1/billing/checkout` | `billing:write` (not for site-restricted tokens) |
| `POST /v1/billing/portal` | `billing:write` (not for site-restricted tokens) |
| `POST /v1/billing/plan` | `billing:write` (not for site-restricted tokens) |
| `POST /v1/billing/webhook` | none; Stripe's signature |