Skip to content

Manage sites

Everything on this page changes something. Endpoints and Query reference cover reading.

MethodPathPermission
POST/sitesManage sites
PATCH/sites/{id}Manage sites
DELETE/sites/{id}Manage sites
GET/sites/{id}/snippetRead analytics
GET PUT/sites/{id}/settings/trackingManage sites
GET PUT/sites/{id}/settings/hostnamesRead / Manage sites
GET PUT/sites/{id}/settings/blocked-ipsRead / Manage sites
GET PUT/sites/{id}/settings/countriesRead / Manage sites
GET PUT/sites/{id}/settings/public-dashboardRead / Manage sites

Before you start

Only the owner may change a site. A site shared with you can be read but not modified: every write answers 403 not_site_owner. The dashboard behaves differently, where deleting a shared site removes your own access instead. The API has no such path, because an agent that receives a success needs the site to actually be gone.

A site you cannot reach answers 404 unknown_site, never 403. Nothing here lets you tell "does not exist" apart from "belongs to someone else".

Writes work on a stopped site. Reading refuses a site whose tracking has lapsed; editing and deleting do not. You can still clean up an account that stopped paying, and you need the snippet exactly when tracking is not live yet.

Create a site

POST /api/v1/sites

Returns the site and its install snippet together, so provisioning is one round trip.

FieldTypeRequiredNotes
urlstringyesFull origin including scheme
timezonestringnoIANA name. Defaults to the server's
hobbyboolnoRequest the free tier. Only certain domains qualify
curl -s -X POST https://statable.com/api/v1/sites \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "timezone": "Europe/Amsterdam"}'
{
  "site_id": 3195799,
  "name": "https://example.com",
  "hash": "07k1eD84d2",
  "timezone": "Europe/Amsterdam",
  "week_start": 1,
  "hobby": false,
  "script_url": "https://statable.com/js/3195799/s.js",
  "snippet": "<script defer src=\"https://statable.com/js/3195799/s.js\"></script>"
}

Install snippet as it comes back rather than assembling the URL yourself.

A URL the account already has is refused with 409 site_exists, not returned. The dashboard hands the existing site back; the API does not, because for an agent a duplicate is nearly always a retry that lost its answer, and two sites counting the same traffic is the worse outcome.

The key must cover all sites. A single-site key gets 403 key_not_scoped: whatever it created would fall outside its own scope the moment it existed.

Billing is unchanged. The first paid site starts the trial exactly as it does in the dashboard.

Idempotent creation

Send an Idempotency-Key header and the first request runs while the next 24 hours of repeats replay its stored answer instead of creating a second site.

curl -s -X POST https://statable.com/api/v1/sites \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Idempotency-Key: provision-example-com-1" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

Reusing a key with a different body is 409 idempotency_conflict rather than a silent replay: that combination means your bookkeeping is broken, and answering with the old result would hide it. A repeat that arrives while the first request is still running gets the same code.

Keys are scoped to your account. Without the header nothing is deduplicated, and site_exists is the only guard left.

Update a site

PATCH /api/v1/sites/{id}

Omitted fields are left alone. Returns the same object as create.

FieldNotes
urlMust start with http:// or https://. 409 site_exists if the account already uses it
timezoneIANA name
week_start0 Sunday through 6 Saturday

Delete a site

DELETE /api/v1/sites/{id}

Answers 204 No Content, and it really deletes: the site, its goals, hostnames, blocklists and import jobs, with the collected events following asynchronously. There is no undo.

Install snippet

GET /api/v1/sites/{id}/snippet

Needs only Read analytics, and shared users may call it. The snippet ends up in your page source, so requiring write permission to read back what to install would be friction with nothing behind it.

?type=globe|live-users|map|countries returns the widget bundle instead of the tracker. An unknown type is 400.

Tracking features

GET /api/v1/sites/{id}/settings/tracking
PUT /api/v1/sites/{id}/settings/tracking

What the installed script actually collects.

{
  "site_id": 3195799,
  "version": 3,
  "bundle": "standard",
  "enabled": ["pageviews", "sessions", "utm"],
  "features": [
    {"id": "pageviews", "label": "Pageviews", "enabled": true, "locked": true,
     "default": true, "size_br": 0},
    {"id": "scroll", "label": "Scroll depth", "enabled": false, "locked": false,
     "default": false, "requires": ["engagement"], "size_br": 214}
  ]
}

enabled is the effective selection. features is the whole catalogue with each entry's state, its dependencies and its marginal compressed cost in bytes, so you can weigh capability against script weight without a second call.

Reading this needs write permission

GET here asks for Manage sites, unlike the rest of the read surface. The catalogue is only useful to a caller allowed to change it, and the same rule guards this setting everywhere else.

PUT replaces the selection wholesale.

curl -s -X PUT https://statable.com/api/v1/sites/3195799/settings/tracking \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"features": ["utm", "scroll", "engagement"]}'
  • features is required. Leaving it out is 400, not "keep what is there": an absent key and an empty list would be indistinguishable, and reading that ambiguity silently would switch everything off.
  • Locked features stay on whether or not you send them.
  • A feature whose requires are unmet is 400 invalid_request, naming the dependency.

The response is the object GET returns, already reflecting the change. Behind it the script is rebuilt and the CDN purged, so the change reaches visitors once the cache clears.

Counting filters

Three settings decide who gets counted. Each GET reads, each PUT replaces the whole list and answers {"site_id": …, "count": N}.

PathBody a PUT takes
settings/hostnames{"allowed": ["www.example.com"], "blocked": []}
settings/blocked-ips["203.0.113.9"], a bare array
settings/countries{"allowed": [], "blocked": ["RU"]}

Reads are not symmetric with writes. Hostnames come back as two lists of plain strings, and blocked-ips as {"blocked_ips": [...]}, but countries come back as one object per entry, {"code": "RU", "created_at": "…"}, while a write takes bare codes. Both keys are always present; an empty list is [], never null.

An empty allowed list means no restriction, not "allow nothing".

Filtering applies to new traffic. Blocking a country does not remove what was already collected from it.

Public dashboard

GET /api/v1/sites/{id}/settings/public-dashboard
PUT /api/v1/sites/{id}/settings/public-dashboard
{"enabled": true}

enabled must be sent explicitly. This is the one setting that changes who can see the data, and a missing field would otherwise read as "turn it off".

With it on, anyone holding the site's hash can read its stats. See Public dashboard and sharing.

Audit trail

Every write here is recorded with the acting key, the IP and the User-Agent: site.create, site.update, site.delete, site.tracking.update, goal.*, funnel.*, and one entry per filter setting.

Next steps


Ready to take control of your web analytics? Try Statable free for 30 days. No credit card required, full feature access, built for GDPR. Start your free trial or view a live demo.