# Sites, hostnames and keys

<!-- docs-test setup: owner -->

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"]}'
```
<!-- expect 201; SITE_ID=.site.id; PUBLIC_KEY=.publicKey.value -->

The answer holds the site and its first public key.

```sh
curl https://api.usherstats.com/v1/sites \
  -H "Authorization: Bearer $US_TOKEN"
```
<!-- expect 200 -->

```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID \
  -H "Authorization: Bearer $US_TOKEN"
```
<!-- expect 200 -->

```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"}'
```
<!-- expect 200 -->

## 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"
```
<!-- expect 200 -->

```html
<script defer src="https://collect.usherstats.com/t.js" data-site="pk_…"></script>
```

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"
```
<!-- expect 200 -->

## 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"}'
```
<!-- expect 201 -->

Each hostname carries `verification`: the DNS record that proves the hostname is yours, a TXT record
`_usherstats.<hostname>` with the value `usherstats-verify=<token>` (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"
```
<!-- expect 200 -->

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"
```
<!-- expect 200 -->

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"
```
<!-- expect 204 -->

## 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"}'
```
<!-- expect 201; SECRET_ID=.key.id -->

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"
```
<!-- expect 200 -->

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"
```
<!-- expect 201; NEW_SECRET_ID=.key.id -->

```sh
curl -X DELETE https://api.usherstats.com/v1/sites/$SITE_ID/keys/$NEW_SECRET_ID \
  -H "Authorization: Bearer $US_TOKEN"
```
<!-- expect 204 -->

## 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"}]
  }'
```
<!-- expect 200 -->

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"]}'
```
<!-- expect 400 -->

## 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"
```
<!-- expect 204 -->

```sh
curl https://api.usherstats.com/v1/sites/$SITE_ID \
  -H "Authorization: Bearer $US_TOKEN"
```
<!-- expect 404 -->
