Manage sites
Everything on this page changes something. Endpoints and Query reference cover reading.
| Method | Path | Permission |
|---|---|---|
POST | /sites | Manage sites |
PATCH | /sites/{id} | Manage sites |
DELETE | /sites/{id} | Manage sites |
GET | /sites/{id}/snippet | Read analytics |
GET PUT | /sites/{id}/settings/tracking | Manage sites |
GET PUT | /sites/{id}/settings/hostnames | Read / Manage sites |
GET PUT | /sites/{id}/settings/blocked-ips | Read / Manage sites |
GET PUT | /sites/{id}/settings/countries | Read / Manage sites |
GET PUT | /sites/{id}/settings/public-dashboard | Read / 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
Returns the site and its install snippet together, so provisioning is one round trip.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Full origin including scheme |
timezone | string | no | IANA name. Defaults to the server's |
hobby | bool | no | Request 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
Omitted fields are left alone. Returns the same object as create.
| Field | Notes |
|---|---|
url | Must start with http:// or https://. 409 site_exists if the account already uses it |
timezone | IANA name |
week_start | 0 Sunday through 6 Saturday |
Delete a site
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
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
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"]}'
featuresis required. Leaving it out is400, 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
requiresare unmet is400 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}.
| Path | Body 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
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
- Goals and funnels for the conversion model
- API keys to mint credentials over the API
- Errors for every code these endpoints return
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.