openapi: 3.2.0 info: title: Statable Stats Sites API version: 1.0.0 description: Read-only public analytics API for Statable. servers: - url: https://statable.com/api/v1 description: Production - url: https://dev.statable.com/api/v1 description: Development security: - bearerAuth: [] tags: - name: Sites description: Create, change and delete sites (write surface). Requires the `sites:write` scope and the server-side `API_WRITE_ENABLED` flag; the snippet route needs only `read`. See docs/api-v1-write-surface.md §5. paths: /sites: post: tags: - Sites operationId: createSite summary: Create a site and get its install snippet description: 'Creates a site and returns everything needed to install tracking, so provisioning is one round-trip. Requires an ALL-SITES key: a single-site key gets 403 `key_not_scoped`, since the new site would be outside its own scope. A duplicate url is 409 `site_exists` — unlike the dashboard, which permits it: for an agent a duplicate is nearly always a retry that lost its response. The first non-hobby site starts the trial subscription exactly as the dashboard does. Send an `Idempotency-Key` to make retries safe.' x-required-scope: sites:write parameters: - name: Idempotency-Key in: header required: false schema: type: string description: 'Replays the stored response for 24 hours instead of creating a second site. Reuse with a different body is 409 `idempotency_conflict`. ' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateSiteRequest' examples: regular: value: url: https://example.com timezone: Europe/Kyiv hobby: summary: Hobby site (domain must match a hobby suffix) value: url: https://myproject.github.io hobby: true responses: '200': description: The created site, with its script URL and snippet. content: application/json: schema: $ref: '#/components/schemas/SiteDetail' '400': description: '`invalid_request` — missing url, bad timezone, or a domain not eligible for hobby.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`insufficient_scope` or `key_not_scoped` (a single-site key).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': $ref: '#/components/responses/WriteDisabled' '409': description: '`site_exists` or `idempotency_conflict`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}: parameters: - name: id in: path required: true schema: type: integer format: int64 description: Numeric site id. patch: tags: - Sites operationId: updateSite summary: Change a site's url, timezone or week start description: 'Owner or admin only — a member gets 403 `not_site_owner`. Omitted fields are left alone. Unlike the read surface there is no tracking-active gate: a lapsed account must still be able to fix and clean up its sites. Another account''s id answers 404 `unknown_site`, exactly as a nonexistent one.' x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateSiteRequest' examples: timezone: value: timezone: UTC week_start: 0 responses: '200': description: The updated site. content: application/json: schema: $ref: '#/components/schemas/SiteDetail' '400': description: '`invalid_request` — bad url, timezone or week_start.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`insufficient_scope`, `key_not_scoped`, or `not_site_owner`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`write_disabled` or `unknown_site`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`site_exists` — the account already uses that url.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' delete: tags: - Sites operationId: deleteSite summary: Delete a site description: 'Owner only — an admin may edit the site but not delete it — and it really deletes: the site plus its goals, hostnames, blocklists and import jobs, with its ClickHouse data removed asynchronously. An admin or a member gets 403 `not_site_owner` — there is no API equivalent of the dashboard''s "remove my access".' x-required-scope: sites:write responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': description: '`insufficient_scope`, `key_not_scoped`, or `not_site_owner`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`write_disabled` or `unknown_site`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/snippet: get: tags: - Sites operationId: getSiteSnippet summary: The install tag for a site description: 'Returns the script URL and the ready-to-paste tag. Scope `read`, not `sites:write`: the snippet is public information (it ends up in the page source), and shared users may install tracking too. `type` selects a widget instead of the tracker: a hobby site gets the bundled `/t/` build, which counts as well as draws, a paid site the widget-only build — its counting is the tracker tag it installs separately.' x-required-scope: read parameters: - name: type in: query required: false schema: type: string enum: - globe - live-users - map - countries description: Omit for the tracking script. responses: '200': description: The install URL and snippet. content: application/json: schema: $ref: '#/components/schemas/SiteSnippet' '400': description: '`invalid_request` — unknown snippet type.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`insufficient_scope` or `key_not_scoped`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`write_disabled` or `unknown_site`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/goals: parameters: - name: id in: path required: true schema: type: integer format: int64 description: Numeric site id. get: tags: - Sites operationId: listSiteGoals summary: The site's goals description: Goal definitions with their ids, for the write calls below. x-required-scope: read responses: '200': description: The site's goals. content: application/json: schema: type: object required: - goals properties: goals: type: array items: $ref: '#/components/schemas/Goal' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' post: tags: - Sites operationId: createGoal summary: Create a goal description: Owner or admin only. A duplicate name is 409 `goal_exists`. x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GoalRequest' examples: page: value: name: Signup path: /thanks operator: e event: value: name: Newsletter event_name: newsletter_signup responses: '200': description: The created goal. content: application/json: schema: $ref: '#/components/schemas/Goal' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '409': description: '`goal_exists`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/goals/{goalId}: parameters: - name: id in: path required: true schema: type: integer format: int64 - name: goalId in: path required: true schema: type: integer format: int64 put: tags: - Sites operationId: updateGoal summary: Replace a goal x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GoalRequest' responses: '200': description: The updated goal. content: application/json: schema: $ref: '#/components/schemas/Goal' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': description: '`write_disabled`, `unknown_site`, or `goal_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`goal_exists`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' delete: tags: - Sites operationId: deleteGoal summary: Delete a goal x-required-scope: sites:write responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': description: '`write_disabled`, `unknown_site`, or `goal_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/funnels: parameters: - name: id in: path required: true schema: type: integer format: int64 description: Numeric site id. post: tags: - Sites operationId: createFunnel summary: Create a funnel description: Owner or admin only. A step pointing at a goal this site does not have is 400, naming the step. A duplicate name is 409 `funnel_exists`. Listing and running funnels lives on the read surface (GET /funnels, POST /funnels/{id}/report). x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/FunnelRequest' examples: pageThenGoal: value: name: Signup flow steps: - kind: page path: / - kind: goal goal_id: 12 responses: '200': description: The created funnel. content: application/json: schema: $ref: '#/components/schemas/FunnelDefinition' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '409': description: '`funnel_exists`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/funnels/{funnelId}: parameters: - name: id in: path required: true schema: type: integer format: int64 - name: funnelId in: path required: true schema: type: integer format: int64 put: tags: - Sites operationId: updateFunnel summary: Replace a funnel x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/FunnelRequest' responses: '200': description: The updated funnel. content: application/json: schema: $ref: '#/components/schemas/FunnelDefinition' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': description: '`write_disabled`, `unknown_site`, or `unknown_funnel`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`funnel_exists`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' delete: tags: - Sites operationId: deleteFunnel summary: Delete a funnel x-required-scope: sites:write responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': description: '`write_disabled`, `unknown_site`, or `unknown_funnel`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/settings/tracking: parameters: - name: id in: path required: true schema: type: integer format: int64 description: Numeric site id. get: tags: - Sites operationId: getTrackingSettings summary: What the installed script collects description: Returns the site's effective feature selection plus the full catalogue — each entry's state, its dependencies and its marginal brotli cost — so a caller can trade capability against script weight without a second call. Owner or admin only. x-required-scope: sites:write responses: '200': description: The site's tracking settings. content: application/json: schema: $ref: '#/components/schemas/TrackingSettings' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`insufficient_scope`, `key_not_scoped`, or `not_site_owner`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`write_disabled` or `unknown_site`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' put: tags: - Sites operationId: setTrackingSettings summary: Set the tracking feature list description: Replaces the selection wholesale. `features` is REQUIRED — an omitted key would be indistinguishable from `[]`, which disables everything optional. Locked features stay enabled whether or not they are sent, and a feature whose `requires` are unmet is rejected. Applying repoints the served bundle, then stores the selection, then purges the CDN — the same order the dashboard uses. Owner or admin only. x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TrackingSettingsRequest' examples: minimal: summary: Only the locked defaults value: features: [] withScroll: value: features: - utm - engagement - scroll responses: '200': description: The settings after the change. content: application/json: schema: $ref: '#/components/schemas/TrackingSettings' '400': description: '`invalid_request` — features omitted, unknown, or a dependency unmet.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`insufficient_scope`, `key_not_scoped`, or `not_site_owner`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`write_disabled` or `unknown_site`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/settings/hostnames: parameters: - name: id in: path required: true schema: type: integer format: int64 get: tags: - Sites operationId: getHostnameSettings summary: Hostname allow/block list x-required-scope: read responses: '200': description: The current setting. content: application/json: schema: $ref: '#/components/schemas/HostnameList' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' put: tags: - Sites operationId: setHostnameSettings summary: Replace the hostname lists description: Owner or admin only; replaces the whole list. Unlike the dashboard equivalent this answers 400/500 on a bad body or a failed write instead of 200 with success:false — a status-checking client must not read a failure as done. x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/HostnameListRequest' responses: '200': description: Applied. content: application/json: schema: $ref: '#/components/schemas/SettingsCount' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/settings/blocked-ips: parameters: - name: id in: path required: true schema: type: integer format: int64 get: tags: - Sites operationId: getBlockedIPs summary: IP blocklist x-required-scope: read responses: '200': description: The current setting. content: application/json: schema: type: object required: - blocked_ips properties: blocked_ips: type: array description: 'Blocked client IPs, as strings. Note the asymmetry with PUT, which takes the bare array as its request body. ' items: type: string '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' put: tags: - Sites operationId: setBlockedIPs summary: Replace the IP blocklist description: Owner or admin only; replaces the whole list. Unlike the dashboard equivalent this answers 400/500 on a bad body or a failed write instead of 200 with success:false — a status-checking client must not read a failure as done. x-required-scope: sites:write requestBody: required: true content: application/json: schema: type: array items: type: string responses: '200': description: Applied. content: application/json: schema: $ref: '#/components/schemas/SettingsCount' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/settings/countries: parameters: - name: id in: path required: true schema: type: integer format: int64 get: tags: - Sites operationId: getCountrySettings summary: Country allow/block list x-required-scope: read responses: '200': description: The current setting. content: application/json: schema: $ref: '#/components/schemas/CountryList' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' put: tags: - Sites operationId: setCountrySettings summary: Replace the country lists description: Owner or admin only; replaces the whole list. Unlike the dashboard equivalent this answers 400/500 on a bad body or a failed write instead of 200 with success:false — a status-checking client must not read a failure as done. x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CountryListRequest' responses: '200': description: Applied. content: application/json: schema: $ref: '#/components/schemas/SettingsCount' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /sites/{id}/settings/public-dashboard: parameters: - name: id in: path required: true schema: type: integer format: int64 get: tags: - Sites operationId: getPublicDashboard summary: Whether the site's stats are world-readable x-required-scope: read responses: '200': description: The current setting. content: application/json: schema: $ref: '#/components/schemas/PublicDashboard' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' put: tags: - Sites operationId: setPublicDashboard summary: Publish or unpublish the site's stats description: 'Owner or admin only. `enabled` must be sent explicitly — this is the one setting that changes who can SEE the data, so an absent field must not read as "turn it off". A hobby site on a free account is always public and answers `409 hobby_always_public` to `enabled: false`. Its stats are the widget it ships, and the hash that addresses them is already in the page''s markup; `enabled: true` on one succeeds as a no-op. An owner with a paid subscription (`active`, or `past_due` during a retried renewal) may turn it off.' x-required-scope: sites:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PublicDashboard' responses: '200': description: Applied. content: application/json: schema: $ref: '#/components/schemas/PublicDashboard' '400': $ref: '#/components/responses/SiteBadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/SiteForbidden' '404': $ref: '#/components/responses/SiteNotFound' '409': description: '`hobby_always_public` — the site is hobby and its owner has no paid subscription, so it cannot be made private.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' components: schemas: FunnelStep: type: object description: 'One step of a funnel definition. `kind` selects which other fields apply. ' required: - kind properties: kind: type: string enum: - page - event - goal - scroll - entry_page - exit_page path: type: string description: URL path — for kind page/entry_page/exit_page. operator: type: string description: Path match operator (e|b|c|r) — for kind page. event: type: string description: Custom event name — for kind event. prop_key: type: string description: Optional custom-property key — for kind event. prop_value: type: string description: Optional custom-property value — for kind event. goal_id: type: integer format: int64 description: Goal id — for kind goal. threshold: type: integer description: Scroll depth 0..100 — for kind scroll. CreateSiteRequest: type: object required: - url properties: url: type: string examples: - https://example.com timezone: type: string description: IANA zone. Omitted = the server default. examples: - Europe/Kyiv hobby: type: boolean default: false description: 'Request a hobby site. The domain must match a configured hobby suffix. ' TrackingSettingsRequest: type: object required: - features properties: features: type: array description: 'The complete desired selection. Send [] to keep only the locked defaults. Omitting the key is an error, not "unchanged". ' items: type: string Error: type: object required: - error - code properties: hint: type: string description: 'One sentence on what to do next. Present only on the errors a client meets while exploring (`not_found`, `method_not_allowed`); other errors omit it. Human-readable — do not branch on it. ' example: 'Use one of: GET, POST.' docs: type: string format: uri description: 'Where to read more. Present together with `hint`, omitted otherwise. ' example: https://statable.com/api/v1/openapi.yaml request_id: type: string description: 'Same value as the X-Request-ID response header, repeated here because clients log bodies more often than headers. Quote it in a support request. Present on every error, including the `not_found` and `method_not_allowed` answers for a path or method that has no route. ' example: ch-node01-01997f2a8b3c7d5e8f01abcdef123456 error: type: string description: Human-readable detail. May be reworded — do not branch on it. code: type: string description: 'Stable machine-readable slug (contract — never changes). The `ambiguous_domain` code is emitted only by the MCP tools (when a `site` domain matches more than one stored site), not by /query. ' enum: - invalid_request - metrics_required - unknown_metric - too_many_dimensions - unknown_dimension - invalid_date_range - invalid_interval - metric_not_available - invalid_filter - event_filter_required - invalid_compare - compare_length_mismatch - site_id_required - limit_offset_misuse - unauthorized - insufficient_scope - key_not_scoped - tracking_inactive - unknown_site - unknown_funnel - rate_limited - internal - ambiguous_domain - invalid_scope - scope_escalation - invalid_expiry - key_limit_reached - api_key_not_found - self_modification - write_disabled - terms_not_accepted - otp_invalid - site_exists - domain_not_allowed - email_undeliverable - not_site_owner - idempotency_conflict - goal_exists - goal_not_found - funnel_exists - hobby_always_public - not_found - method_not_allowed FunnelRequest: type: object required: - name - steps properties: name: type: string steps: type: array minItems: 2 items: $ref: '#/components/schemas/FunnelStep' scope: type: string enum: - visitor - session default: visitor strict_order: type: boolean CountryListRequest: type: object description: ISO 3166-1 alpha-2 codes. Replaces both lists. properties: allowed: type: array items: type: string blocked: type: array items: type: string GoalRequest: type: object required: - name properties: name: type: string path: type: string description: URL path to match. operator: type: string enum: - e - b - c default: e description: e = equals, b = begins with, c = contains. event_name: type: string description: Match a custom event instead of a path. scroll_depth: type: integer description: Percent scrolled. TrackingFeatureState: type: object required: - id - label - enabled - locked - default - size_br properties: id: type: string label: type: string enabled: type: boolean locked: type: boolean description: Always collected; cannot be turned off. default: type: boolean requires: type: array items: type: string description: Feature ids that must also be enabled. size_br: type: integer description: Marginal cost in bytes, brotli-compressed. SiteSnippet: type: object required: - site_id - script_url - snippet properties: site_id: type: integer format: int64 script_url: type: string snippet: type: string description: 'The tag to paste, ready as it stands — install it verbatim rather than rebuilding it from `script_url`. A hobby site''s tag carries a `data-id` attribute as well: its script is a `/t/` bundle addressed by hash, and that attribute is the only place the counter inside it can read the site id. A tag for a paid site needs no attributes. ' examples: - - Goal: type: object required: - id - host_id - name properties: id: type: integer format: int64 host_id: type: integer format: int64 name: type: string path: type: string operator: type: string event_name: type: string scroll_depth: type: integer created_at: type: string format: date-time updated_at: type: string format: date-time CountryEntry: type: object required: - code - created_at properties: code: type: string description: ISO 3166-1 alpha-2. example: DE created_at: type: string format: date-time description: When the country was added to the list. HostnameList: type: object description: The stored lists, as GET returns them. Both keys are always present; an empty list is []. required: - allowed - blocked properties: allowed: type: array items: type: string blocked: type: array items: type: string TrackingSettings: type: object required: - site_id - version - bundle - enabled - features properties: site_id: type: integer format: int64 version: type: integer description: Catalogue version. bundle: type: string description: The build actually served for this site. enabled: type: array items: type: string features: type: array items: $ref: '#/components/schemas/TrackingFeatureState' UpdateSiteRequest: type: object description: Omitted fields are left unchanged. properties: url: type: string description: Must start with http:// or https://. timezone: type: string week_start: type: integer minimum: 0 maximum: 6 description: 0 = Sunday … 6 = Saturday. PublicDashboard: type: object required: - enabled properties: site_id: type: integer format: int64 readOnly: true enabled: type: boolean HostnameListRequest: type: object description: Replaces both lists. Send [] to clear one. properties: allowed: type: array items: type: string blocked: type: array items: type: string CountryList: type: object description: 'The stored lists, as GET returns them. Note the deliberate asymmetry with the PUT body: writes take bare ISO codes, reads return one entry per country with the time it was added. Both keys are always present; an empty list is []. ' required: - allowed - blocked properties: allowed: type: array items: $ref: '#/components/schemas/CountryEntry' blocked: type: array items: $ref: '#/components/schemas/CountryEntry' SettingsCount: type: object required: - site_id - count properties: site_id: type: integer format: int64 count: type: integer description: Entries stored after the replace. FunnelDefinition: type: object description: A saved funnel definition (managed in the dashboard). properties: id: type: integer format: int64 site_id: type: integer format: int64 name: type: string scope: type: string enum: - visitor - session strict_order: type: boolean description: When true, steps must occur in exact order (windowFunnel strict_order). steps: type: array items: $ref: '#/components/schemas/FunnelStep' created_at: type: string format: date-time updated_at: type: string format: date-time SiteDetail: type: object required: - site_id - name - hash - timezone - week_start - hobby - script_url - snippet properties: site_id: type: integer format: int64 name: type: string description: The site url as stored. hash: type: string description: Same token as in SiteSummary.hash. timezone: type: string week_start: type: integer hobby: type: boolean script_url: type: string snippet: type: string description: The ready-to-paste script tag. headers: X-RateLimit-Remaining: description: Requests left in the current per-key window. schema: type: integer X-RateLimit-Reset: description: Seconds until the per-key window resets (delta-seconds, not an epoch). schema: type: integer X-RateLimit-Limit: description: The per-key hourly rate limit for your plan. schema: type: integer X-Request-ID: description: 'Correlation id for support, present on every response including successful ones. The leading segment names the node that served the request, so this one value is enough to locate the log entry. Error bodies repeat it as `request_id`. A client-supplied X-Request-ID is recorded server-side but never echoed back in place of ours. ' schema: type: string example: ch-node01-01997f2a8b3c7d5e8f01abcdef123456 responses: Internal: description: 'Unexpected server error (`internal`). Report it with the `request_id` from the body or the X-Request-ID header: it is what lets the exact log entry be found, and without it a 500 can only be matched by guessing at a time window. ' headers: X-Request-ID: $ref: '#/components/headers/X-Request-ID' content: application/json: schema: $ref: '#/components/schemas/Error' SiteBadRequest: description: '`invalid_request` — the body or a field is not acceptable.' content: application/json: schema: $ref: '#/components/schemas/Error' SiteForbidden: description: '`insufficient_scope`, `key_not_scoped`, or `not_site_owner`.' content: application/json: schema: $ref: '#/components/schemas/Error' Unauthorized: description: Missing/malformed/invalid/expired/revoked bearer token (`unauthorized`). content: application/json: schema: $ref: '#/components/schemas/Error' SiteNotFound: description: '`write_disabled` or `unknown_site`.' content: application/json: schema: $ref: '#/components/schemas/Error' RateLimited: description: 'Hourly account (default 2000/h) or per-key (default 600/h) limit hit (`rate_limited`). Retry after the `Retry-After` seconds. The X-RateLimit-* headers report the window that tripped (account on an account limit). ' headers: Retry-After: description: Seconds until you may retry. schema: type: integer X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: $ref: '#/components/schemas/Error' WriteDisabled: description: '`write_disabled` — the write surface is off (`API_WRITE_ENABLED=false`). 404 rather than 403 so a disabled surface is not advertised. ' content: application/json: schema: $ref: '#/components/schemas/Error' securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: stbl_ description: 'A key minted in Settings → API. All tokens start with `stbl_`. Missing, malformed, invalid, expired, or revoked → 401. Every operation in this spec requires the key''s `read` scope; without it → 403 `insufficient_scope`. '