This is the full developer documentation for CanaryLytics # CanaryLytics > Cookieless web analytics. No cookies, no fingerprinting, no raw IPs, and the API key is the whole account. ## What is CanaryLytics? [Section titled “What is CanaryLytics?”](#what-is-canarylytics) CanaryLytics is a cookieless web analytics product built around one idea: **the API key is the account**. There is no email/password signup. A single secret key (`cly_live_<32 hex>`) logs you into the dashboard, authorizes SDK and REST reads, and authorizes the embeddable widget. It is locked to the domains you register, so it can be rotated at any time and never needs to appear in a page’s source. The public tracking snippet carries **no key at all**. It reports the domain it runs on; the ingest endpoint resolves your project from that domain and checks the request’s `Origin` header against your trusted-domain list before accepting anything. No key on the page The snippet is keyless. Nothing secret ever ships in your page source, so there’s nothing to steal from view-source. Cookieless by design Sessions live in tab-scoped `sessionStorage`. No cookies, no cross-site identifiers, no consent banners required. Silent by default `/ingest` always answers `204`. A misconfigured snippet fails quietly instead of leaking information about your setup to strangers. ## Add it to a site [Section titled “Add it to a site”](#add-it-to-a-site) One script tag. No key, no config file, no build step: ```html ``` Pageviews, SPA navigations, link clicks, sessions, traffic sources, and UTM campaigns are tracked automatically. The [quickstart](/docs/quickstart/) takes you from zero to live data in under five minutes. ## Why cookieless [Section titled “Why cookieless”](#why-cookieless) Every event CanaryLytics stores is scoped to a single browser tab’s session. There is no cross-site identifier, no persistent cookie and no device fingerprinting. A visitor who closes the tab and comes back later is a new, unlinked session, and that is deliberate. Cross-origin referrers contribute only their **hostname** (never path or query), so search terms and personal URLs never leave the visitor’s browser. ## Privacy stance [Section titled “Privacy stance”](#privacy-stance) Raw IP addresses are never written to the database. What gets stored is the coarse geo Cloudflare’s edge has already resolved: country, region, city, ASN. There is no fingerprint and no user id, so nothing links a visitor across sessions, devices or sites. Bots and datacenter traffic are labelled rather than dropped. You can filter them out of any report, but the underlying events stay, so the numbers never quietly disagree with your server logs. Referrers get classified into a traffic-source label (search, social, direct, referral) and the raw referrer is then discarded. ## Frequently asked questions [Section titled “Frequently asked questions”](#frequently-asked-questions) ### Do I need a cookie consent banner to use CanaryLytics? [Section titled “Do I need a cookie consent banner to use CanaryLytics?”](#do-i-need-a-cookie-consent-banner-to-use-canarylytics) No. CanaryLytics sets no cookies and stores no cross-site identifiers. Sessions live in tab-scoped `sessionStorage` and end when the tab closes, so there is nothing for a consent banner to disclose. ### What data does CanaryLytics store about a visitor? [Section titled “What data does CanaryLytics store about a visitor?”](#what-data-does-canarylytics-store-about-a-visitor) Coarse geolocation from Cloudflare’s edge (country, region, city, ASN), coarse browser, OS, and device-type buckets, a truncated user agent, a traffic-source label, and a random tab-scoped session id. Never a raw IP address, fingerprint, or user id. See [what gets stored](/docs/how-it-works/#what-gets-stored) for the full breakdown. ### Can someone steal my analytics key from my page source? [Section titled “Can someone steal my analytics key from my page source?”](#can-someone-steal-my-analytics-key-from-my-page-source) No. The tracking snippet carries no key at all, because ingest resolves your project from the domain it reports. The only key that belongs in public HTML is the [read-only key](/docs/keys/) (`cly_read_…`), which can read stats but can never change settings or write events. ### Does the tracking snippet slow my site down? [Section titled “Does the tracking snippet slow my site down?”](#does-the-tracking-snippet-slow-my-site-down) No. `lytics.js` is \~2.5 KB min+gzip with zero dependencies (the size budget is enforced by the build), loads with `defer`, and sends events with `fetch` keepalive, so it never blocks rendering or navigation. ### Does CanaryLytics work with single-page apps? [Section titled “Does CanaryLytics work with single-page apps?”](#does-canarylytics-work-with-single-page-apps) Yes, automatically. The snippet patches `history.pushState`/`replaceState` and listens for `popstate`, so client-side route changes fire pageviews with no extra configuration. Hash-based routers opt in with [`data-hash`](/docs/environments/spas/#hash-router-spas). ## Explore the docs [Section titled “Explore the docs”](#explore-the-docs) [Quickstart](/docs/quickstart/)Zero to live data in under five minutes. [How it works](/docs/how-it-works/)The key-is-account model, trusted domains, and silent drops. [REST API](/docs/api/rest/)Every /v1 endpoint, auth, rate limits, and CORS. [SDK](/docs/api/sdk/)The typed @canarylytics/sdk client and React hooks. ## Use these docs with AI assistants [Section titled “Use these docs with AI assistants”](#use-these-docs-with-ai-assistants) Every page of this documentation is available as plain markdown, following the [llms.txt convention](https://llmstxt.org/): * [`/docs/llms.txt`](/docs/llms.txt) indexes every page with a one-line summary. * [`/docs/llms-full.txt`](/docs/llms-full.txt) is the whole documentation in one file, for large context windows. * [`/docs/llms-small.txt`](/docs/llms-small.txt) is the abridged version, for small ones. The same files are mirrored at the site root (`/llms.txt`, `/llms-full.txt`, `/llms-small.txt`) for automatic discovery. # Quickstart > Add CanaryLytics to any site in under five minutes. Create a project, paste one script tag, and verify events are flowing with /v1/validate. Adding CanaryLytics to a site takes three things: a project, one script tag, and a page load. No account signup, no config file, no build step. 1. ### Create a project [Section titled “Create a project”](#create-a-project) Projects are invite-gated. Open the CanaryLytics dashboard, paste your invite code, name the project, and list the domain(s) it will run on (for example `myapp.com`). You get back a secret key: ```plaintext cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0 ``` Shown once The plaintext key is displayed exactly once, at creation. Only its HMAC hash is stored server-side. If you lose the key, [rotate it](/docs/keys/#rotation) from the dashboard’s Settings page to get a new one. 2. ### Add the tracking snippet [Section titled “Add the tracking snippet”](#add-the-tracking-snippet) Paste this before `` (or anywhere in ``) on every page you want tracked. The snippet never carries a key. `data-domain` just needs to match one of the project’s trusted domains: ```html ``` If you omit `data-domain`, it defaults to `location.hostname` at runtime. 3. ### Verify the setup [Section titled “Verify the setup”](#verify-the-setup) `/ingest` always answers `204`, even when an event is silently dropped, so there is no error to watch for in the browser. Dry-run the acceptance rules with your key instead: ```bash curl "https://api.analytics.canarycoders.es/v1/validate?domain=myapp.com&origin=https://myapp.com" \ -H "Authorization: Bearer cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0" ``` ```json { "domain": "myapp.com", "origin": "https://myapp.com", "wouldAccept": true, "checks": { "...": "..." }, "reasons": [] } ``` `wouldAccept: true` means a real visitor’s events will be accepted. `wouldAccept: false` comes with a `reasons` array explaining exactly why. See [troubleshooting](/docs/troubleshooting/) for the field-by-field walkthrough. 4. ### Watch the data arrive [Section titled “Watch the data arrive”](#watch-the-data-arrive) Load a page on the tracked domain, then open the CanaryLytics dashboard. Sessions, pageviews, and the live-visitors counter update within a couple of seconds. ## Next steps [Section titled “Next steps”](#next-steps) [Install anywhere](/docs/environments/static-sites/)SPAs, hash routers, SSR frameworks, Electron, and Capacitor. [Custom events & goals](/docs/tracking/events-and-goals/)Track signups and conversions from the browser or your server. [Embed the widget](/docs/widget/)Live stats in your own admin panel, using the read-only key. [How it works](/docs/how-it-works/)Why there's no signup, and why /ingest never returns an error. # How it works > CanaryLytics has no user accounts. A secret API key is the account, projects are locked to trusted domains, and rejected events are dropped silently so the endpoint never becomes an oracle. CanaryLytics has no email/password accounts. Three ideas explain almost everything about how it behaves: the key is the account, projects are locked to trusted domains, and rejected events disappear without a word. ## The key is the account [Section titled “The key is the account”](#the-key-is-the-account) A project has a single secret key, `cly_live_<32 hex chars>` (16 random bytes, hex-encoded). That one key logs you into the dashboard, where pasting it in is the whole session. It also authorizes SDK and REST reads, the embeddable widget, and server-side custom event ingestion through `POST /v1/events`. The server never stores the plaintext, only `HMAC-SHA256(key, KEY_HMAC_SECRET)`. The plaintext is shown exactly once, at creation or rotation. A database dump alone is useless without the separate server-side secret. A project can also hold **one read-only companion key**, `cly_read_<32 hex chars>`, minted separately. It authorizes every `/v1` `GET`, the widget, and dashboard login in read-only mode. What it cannot do is write: `POST /v1/events` answers `403 read_only_key`, and every dashboard mutation is rejected. The read-only key is the one that belongs in public HTML. See [Keys & security](/docs/keys/) for the full comparison. ## Trusted domains [Section titled “Trusted domains”](#trusted-domains) Every project owns an allowlist of domains. Two independent checks use it: 1. **The tracking snippet carries no key.** It reports a `domain` (from `data-domain`, defaulting to `location.hostname`). The ingest endpoint resolves the *project* from that domain, which works because domains are **globally unique**: a domain belongs to exactly one project. 2. **The request’s `Origin` header must also match a trusted domain**, or the event is dropped. This is what stops someone copying your `data-domain` value onto an untrusted site and pushing fake events into your project. Matching is always on **domain-label boundaries**, never substring: a host matches a trusted domain `d` when `host === d` or `host.endsWith("." + d)`. So `app.myapp.com` matches `myapp.com`, but `evil-myapp.com` never does. Browser reads (the widget, or an SDK client running in a browser) go through the same trust check from the other direction. The `/v1` CORS middleware only reflects `Access-Control-Allow-Origin` for the authenticated project’s trusted domains, dashboard origins, or localhost-ish origins when the project’s “Allow local origins” toggle is on. An untrusted page cannot read your data, key or no key. ## Silent drops: why /ingest never returns an error [Section titled “Silent drops: why /ingest never returns an error”](#silent-drops-why-ingest-never-returns-an-error) No error oracle `POST /ingest` **always returns `204`**. A malformed body, an unknown domain, a missing or mismatched `Origin`, a localhost origin without the toggle, an over-cap event: all silently dropped. The client never learns which happened. This is deliberate. If `/ingest` told the caller *why* an event was rejected, that response would double as a domain-ownership and configuration oracle for anyone probing the endpoint from outside. The sanctioned way to debug a silent drop is `GET /v1/validate`. It requires your key, mirrors the exact acceptance logic ingest uses, and returns a `wouldAccept` boolean plus human-readable `reasons`. Because it is key-authed, it can safely explain drops for *your* project without ever revealing whether a domain belongs to someone else’s. The [troubleshooting guide](/docs/troubleshooting/) walks through it field by field, including the failure modes it can’t see (CSP, adblockers, and the daily event cap). ## What gets stored [Section titled “What gets stored”](#what-gets-stored) Each event is a `page_view`, `link_click`, or `custom` row, scoped to a tab-lived `sessionStorage` session id. No cookies, no user identifiers. | Data | What is stored | What is never stored | | --------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Session | Random tab-scoped UUID | Cookies, cross-session or cross-device identifiers | | Referrer | Cross-origin: hostname only, classified into a traffic-source label; same-origin: path | Cross-origin paths or query strings (search terms never leave the browser) | | Location | Country, region, city, ASN from Cloudflare’s edge | Raw IP addresses | | Device | Coarse browser/OS/device-type buckets, User-Agent truncated to 512 chars | Fingerprints | | Traffic quality | Bot labels, VPN/datacenter flags | Nothing is blocked or filtered. Bots are labelled, VPNs are flagged, and both stay visible | ## Related [Section titled “Related”](#related) [Keys & security](/docs/keys/)Live vs read-only keys, storage, and rotation. [Troubleshooting](/docs/troubleshooting/)Debug silent drops with GET /v1/validate. # MCP server > CanaryLytics ships a hosted Model Context Protocol server at /mcp, so any MCP client can query your analytics with the same numbers the dashboard shows. CanaryLytics exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://api.analytics.canarycoders.es/mcp`, speaking streamable HTTP. Point an MCP client at it and the client’s model can read your traffic directly: every tool wraps the same query layer as the dashboard and [`/v1/stats`](/docs/api/rest/), so the numbers can never diverge. All tools are read-only. A read-only key (`cly_read_…`) gets the full surface, which makes it the right key to hand to an agent. ## Connect a client [Section titled “Connect a client”](#connect-a-client) Any client that supports the streamable HTTP transport with custom headers works. Authentication is the same Bearer header as the REST API. Most JSON-configured clients accept this shape: ```json { "mcpServers": { "canarylytics": { "url": "https://api.analytics.canarycoders.es/mcp", "headers": { "Authorization": "Bearer cly_read_..." } } } } ``` Note Clients that only support OAuth for remote servers can’t send a static Bearer header. Use a client with header support, or bridge with a local proxy such as `mcp-remote`. ## Authentication and limits [Section titled “Authentication and limits”](#authentication-and-limits) The `Authorization: Bearer ` header is required; without a valid key every request answers `401 unauthorized`. Requests share the REST API’s read budget of 120 requests per minute per key, so a busy agent can hit `429 rate_limited` with a `Retry-After` header. See [Keys](/docs/keys/) for how live and read-only keys relate. The server is stateless: no session ids, no server-side conversation state, one self-contained JSON-RPC exchange per request. ## Tools [Section titled “Tools”](#tools) Stats tools take `window` (`24h`, `7d`, `30d`, `90d`; default `7d`) and `includeBots` (default `false`, bots excluded). | Tool | Arguments | Returns | | --------------------- | ---------------------------------- | ------------------------------------------------ | | `get_project` | none | Name, trusted domains, retention, caps | | `get_overview` | window, includeBots | Visitors, pageviews, bounce rate, visit duration | | `get_timeseries` | window, includeBots | Visitors and pageviews over time | | `get_top_pages` | window, includeBots | Most-visited paths | | `get_traffic_sources` | window, includeBots | Search, social, direct, referral breakdown | | `get_countries` | window, includeBots | Visitors by country | | `get_devices` | window, includeBots | Browser, OS, device-type breakdowns | | `get_utm` | window, includeBots | Campaign performance by UTM parameters | | `get_custom_events` | window, includeBots | Counts per custom event name | | `get_goals` | window, includeBots | Goal conversions and rates | | `get_realtime` | none | Visitors active in the last 5 minutes | | `get_recent_sessions` | window, includeBots, limit, offset | Paginated session list, newest first | | `get_session` | sessionId | Full event trail of one session | Results come back as JSON in the tool’s text content, in the same shapes as the [REST API](/docs/api/rest/) responses. ## Raw protocol example [Section titled “Raw protocol example”](#raw-protocol-example) The endpoint is plain JSON-RPC 2.0 over HTTP POST, so it is easy to probe without a client: ```bash curl https://api.analytics.canarycoders.es/mcp \ -H "Authorization: Bearer cly_read_..." \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_overview","arguments":{"window":"30d"}}}' ``` ## Related [Section titled “Related”](#related) [REST API](/docs/api/rest/)The /v1 endpoints these tools wrap. [Keys](/docs/keys/)Live vs read-only keys, rotation, and what each may do. # REST API > The CanaryLytics /v1 REST API exposes every stat the dashboard shows, over Bearer-authed JSON endpoints for stats, sessions, exports, custom events and setup validation. The `/v1` REST API at `https://api.analytics.canarycoders.es` exposes every stat the dashboard shows, over Bearer-authed JSON endpoints. Every route is scoped to the project that owns the key. There is no cross-project read path. ## Endpoints at a glance [Section titled “Endpoints at a glance”](#endpoints-at-a-glance) | Method & path | Key required | Returns | | ---------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------- | | `POST /v1/projects` | invite code (no key) | New project + plaintext key, shown once | | `GET /v1/project` | live or read | Project info (`ProjectInfo`) | | `GET /v1/validate` | live or read | Dry-run of the ingest acceptance rules | | `GET /v1/stats/*` | live or read | Overview, timeseries, pages, sources, countries, regions, events, UTM, devices, goals, realtime | | `GET /v1/sessions` · `/:sessionId` | live or read | Paginated session list, session detail | | `GET /v1/export` | live or read | Cursor-paginated raw event dump (CSV/NDJSON) | | `POST /v1/events` | **live only** | `202`, records a server-side custom event | ## Authentication [Section titled “Authentication”](#authentication) ```plaintext Authorization: Bearer cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0 ``` A `cly_live_…` key authorizes every endpoint. A `cly_read_…` key authorizes everything **except** `POST /v1/events`, which answers `403 read_only_key`. `POST /v1/projects` is the only unauthenticated route. See [provisioning](#provisioning). Errors are always `{ "error": { "code": "...", "message": "..." } }`, with the HTTP status carrying the usual meaning (`400`, `401`, `403`, `404`, `429`). ## Rate limit [Section titled “Rate limit”](#rate-limit) 120 requests/minute per key, fixed 60-second window. Over the limit you get `429 rate_limited` with a `Retry-After` header (seconds until the window resets). ## CORS [Section titled “CORS”](#cors) `/v1` responses reflect `Access-Control-Allow-Origin` only when the request’s `Origin` matches the authenticated project’s trusted domains, the dashboard origin, or a localhost-ish origin while the project’s “Allow local origins” toggle is on. Server-to-server calls (no `Origin` header) skip this check entirely. It only matters for browser callers such as the widget or an SDK client running in a browser. An untrusted browser `Origin` is **not** a `403`: the request succeeds, but the response omits `Access-Control-Allow-Origin`, so the browser refuses to hand your script the body. This is why a call can work in `curl` (and any server-side caller) yet fail in the browser. Add the page’s domain to the project’s trusted domains to fix it. ## Provisioning [Section titled “Provisioning”](#provisioning) ```plaintext POST /v1/projects ``` The one unauthenticated route. It is gated by an invite code instead of a key, since no key exists yet. ```bash curl https://api.analytics.canarycoders.es/v1/projects \ -H "Content-Type: application/json" \ -d '{"inviteCode":"...","name":"My App","domains":["myapp.com"]}' ``` Returns `201` with `{ apiKey, project }`, where `apiKey` is the plaintext key, shown exactly once. `403 invalid_invite_code` on a bad invite; `400 bad_request` on a malformed body or invalid domain list; `409 conflict` if a domain is already claimed by another project. ## Project [Section titled “Project”](#project) ```plaintext GET /v1/project ``` Returns `ProjectInfo`: `id`, `name`, `keyPrefix`, `domains`, `retentionDays`, `dailyEventCap`, `createdAt`, `readKeyPrefix` (`null` if none minted), `allowLocalOrigins`. ## Validate [Section titled “Validate”](#validate) ```plaintext GET /v1/validate?domain=myapp.com&origin=https://myapp.com ``` Dry-runs the `/ingest` acceptance rules for the authenticated project. See the [troubleshooting guide](/docs/troubleshooting/#walking-through-v1validate) for a field-by-field walkthrough. ## Stats [Section titled “Stats”](#stats) All `GET /v1/stats/*` routes take `?window=24h|7d|30d|90d` (default `7d`) and, except `realtime`, `?includeBots=true|1` (default `false`): ```plaintext GET /v1/stats/overview GET /v1/stats/timeseries GET /v1/stats/pages GET /v1/stats/sources GET /v1/stats/countries GET /v1/stats/regions?country=ES (country is required) GET /v1/stats/events GET /v1/stats/utm GET /v1/stats/devices GET /v1/stats/goals GET /v1/stats/realtime ``` ```bash curl "https://api.analytics.canarycoders.es/v1/stats/overview?window=30d" \ -H "Authorization: Bearer cly_read_a1b2c3d4e5f60718293a4b5c6d7e8f90" ``` `/v1/stats/realtime` is the odd one out: it returns `{ activeSessions }`, meaning distinct non-bot sessions with at least one event in the last 5 minutes, and it ignores `window` entirely. Bots are always excluded (a crawler is never a “current visitor”), which is why it has no `includeBots` toggle. These are thin wrappers over the same query layer the dashboard uses, so numbers can never drift between the widget/SDK and the dashboard UI. ## Sessions [Section titled “Sessions”](#sessions) ```plaintext GET /v1/sessions?window=7d&limit=50&offset=0&country=ES®ionCode=ES-MD&includeBots=false GET /v1/sessions/:sessionId ``` `limit` is 1–200 (default 50); the list response includes `hasMore`. The detail route 404s (`not_found`) if the session has no recorded events. ## Export [Section titled “Export”](#export) ```plaintext GET /v1/export?window=7d&format=csv&limit=1000&cursor=... ``` A cursor-paginated raw event dump. `format` is `csv` (default) or `ndjson`; `limit` is 1–10000 (default 1000). The response body **is** the page of data. A next-page cursor arrives in the `X-Next-Cursor` response header, present only when the page filled up completely (an exactly-full last page costs one extra empty request to confirm there is nothing more). ```bash curl "https://api.analytics.canarycoders.es/v1/export?window=7d&format=ndjson&limit=5000" \ -H "Authorization: Bearer cly_read_a1b2c3d4e5f60718293a4b5c6d7e8f90" \ -D - -o events.ndjson ``` Both formats emit the same columns, in a fixed order: `id, createdAt, sessionId, eventType, eventName, props, path, referrerPath, targetPath, country, region, regionCode, city, asn, asOrganization, browser, os, deviceType, isBot, botName, isVpn, locale, trafficSource, utmSource, utmMedium, utmCampaign, userAgent`. ## Track a custom event [Section titled “Track a custom event”](#track-a-custom-event) ```plaintext POST /v1/events ``` Full key only. Read keys get `403 read_only_key`. ```bash curl https://api.analytics.canarycoders.es/v1/events \ -H "Authorization: Bearer cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0" \ -H "Content-Type: application/json" \ -d '{"name":"signup","props":{"plan":"pro"},"path":"/onboarding"}' ``` `202 { "accepted": true }`, or `429 daily_cap_exceeded` if the project’s daily event cap is reached. See [Custom events, goals & UTM tracking](/docs/tracking/events-and-goals/) for the field constraints. Tip Prefer a typed client over raw `curl`? The [SDK](/docs/api/sdk/) wraps every one of these routes with request building, auth headers, and typed responses. ## Related [Section titled “Related”](#related) [SDK](/docs/api/sdk/)The typed @canarylytics/sdk client for these endpoints. [MCP server](/docs/api/mcp/)The same stats as MCP tools for any MCP-capable agent. [Keys & security](/docs/keys/)Which key to use where: live vs read-only. # SDK > @canarylytics/sdk is a typed, isomorphic client for the CanaryLytics REST API. It runs in browsers, Node, Workers and Electron, with optional React hooks for polling. `@canarylytics/sdk` is a typed, isomorphic client for the [REST API](/docs/api/rest/). It only needs a global `fetch`, so it runs unmodified in browsers, Node ≥ 18, Cloudflare Workers, and an Electron main process. Every wire type ships in the package itself (`ProjectInfo`, `StatsOverview`, `SessionDetail`, …), so responses are fully typed. ```bash npm install @canarylytics/sdk ``` ## Create a client [Section titled “Create a client”](#create-a-client) ```ts import { createClient } from "@canarylytics/sdk"; const lytics = createClient({ apiKey: process.env.CANARYLYTICS_KEY!, // cly_live_… or cly_read_… baseUrl: "https://api.analytics.canarycoders.es", // optional, this is the default }); ``` `createClient` throws immediately if `apiKey` is missing, and again at call time if no global `fetch` is available and you didn’t pass one via `config.fetch`. ## Call the API [Section titled “Call the API”](#call-the-api) Every method maps one-to-one onto a REST endpoint: ```ts await lytics.project.info(); await lytics.stats.overview({ window: "7d" }); await lytics.stats.timeseries({ window: "30d" }); await lytics.stats.pages({ window: "7d" }); await lytics.stats.sources({ window: "7d" }); await lytics.stats.countries({ window: "7d" }); await lytics.stats.regions({ window: "7d", country: "ES" }); await lytics.stats.events({ window: "7d" }); await lytics.stats.utm({ window: "7d" }); await lytics.stats.devices({ window: "7d" }); await lytics.stats.goals({ window: "7d" }); await lytics.stats.realtime(); await lytics.sessions.list({ window: "24h", limit: 50 }); await lytics.sessions.get(sessionId); // null on 404, doesn't throw await lytics.validate({ domain: "myapp.com", origin: "https://myapp.com" }); await lytics.exportEvents({ window: "7d", format: "ndjson" }); await lytics.events.track("signup", { plan: "pro" }, { path: "/onboarding" }); ``` ## Handle errors [Section titled “Handle errors”](#handle-errors) Every non-2xx response throws a typed `LyticsApiError`: ```ts import { LyticsApiError } from "@canarylytics/sdk"; try { await lytics.events.track("signup"); } catch (err) { if (err instanceof LyticsApiError) { // err.status (e.g. 403), err.code (e.g. "read_only_key"), err.message // 401 → code "unauthorized", message // "A valid API key is required (Authorization: Bearer cly_live_…)" } } ``` ## Provision a project [Section titled “Provision a project”](#provision-a-project) Creating a project happens before any key exists, so it is a standalone function rather than a client method: ```ts import { provisionProject } from "@canarylytics/sdk"; const { apiKey, project } = await provisionProject("https://api.analytics.canarycoders.es", { inviteCode: "...", name: "My App", domains: ["myapp.com"], }); ``` ## React hooks [Section titled “React hooks”](#react-hooks) ```bash npm install @canarylytics/sdk react ``` ```tsx import { useLyticsClient, useOverview, useRealtime } from "@canarylytics/sdk/react"; function Dashboard() { const client = useLyticsClient({ apiKey: readOnlyKey }); const overview = useOverview(client, { window: "7d", refreshInterval: 5000 }); const live = useRealtime(client, { refreshInterval: 2000 }); if (overview.isLoading) return

Loading…

; if (overview.error) return

{overview.error.message}

; return

{overview.data?.totals.sessions} sessions · {live.data?.activeSessions} live

; } ``` Every hook (`useOverview`, `useTimeseries`, `useTopPages`, `useSources`, `useCountries`, `useCustomEvents`, `useUtm`, `useDevices`, `useGoals`, `useRealtime`, `useSessions`) returns `{ data, error, isLoading, refresh }` and accepts the matching stats query plus `refreshInterval` (milliseconds; omit or pass `0` to fetch once). Polling pauses while the tab is hidden and refetches immediately when it becomes visible again, so data doesn’t go stale in a backgrounded tab. `react >= 18` is an optional peer dependency, needed only if you import from the `/react` subpath. Note Import from `"@canarylytics/sdk"` for the core client and `"@canarylytics/sdk/react"` for the hooks. They are separate entry points, so a non-React consumer such as a server script or an Electron main process doesn’t need React installed at all. ## Related [Section titled “Related”](#related) [REST API](/docs/api/rest/)The endpoints behind every SDK method. [Custom events & goals](/docs/tracking/events-and-goals/)Track conversions with events.track(). # Capacitor & Ionic > Capacitor and Ionic apps load from capacitor://localhost, so CanaryLytics needs the project's "Allow local origins" toggle enabled. data-domain stays your real domain. Capacitor and Ionic apps load the web view from `capacitor://localhost` (or `ionic://localhost` on older setups). In both cases the page’s **hostname is literally `localhost`**, even though this is a production app on someone’s phone rather than a dev server. Enable the project’s **“Allow local origins”** toggle (dashboard → Settings) so ingest and the widget/SDK’s CORS layer accept it. The toggle covers `localhost`, `127.0.0.1`, and `*.localhost` origins for the project. ```html ``` `data-domain` should still be your real production domain (or whatever value you want events grouped under). The toggle is about which *Origin* is trusted, not what domain the snippet reports. This toggle is shared with dev “Allow local origins” also governs plain `localhost` development traffic on this project (see [Localhost & development](/docs/environments/localhost/)). Turning it off to “clean up” dev noise will also silently stop ingestion from every Capacitor/Ionic install of this app. The two use cases share the same hostname and can’t be told apart at the Origin-check level. ## Related [Section titled “Related”](#related) [Localhost & development](/docs/environments/localhost/)The other use case behind the same toggle. [Electron](/docs/environments/electron/)The desktop equivalent, using custom protocol schemes instead. # Electron > Track an Electron app with CanaryLytics. Remote URLs work as-is; bundled apps need a custom protocol scheme, because file:// origins can never be trusted. Electron apps track like websites as long as the page has a real origin. A `BrowserWindow` loading a remote URL needs nothing special; a bundled app needs a custom protocol scheme, because `file://` pages have no origin to trust. ## Loading a remote URL [Section titled “Loading a remote URL”](#loading-a-remote-url) If your `BrowserWindow` loads a real `https://` URL (for example `win.loadURL("https://myapp.com")`), it works exactly like a normal web page. The page’s origin is `https://myapp.com`, which just needs to be a trusted domain like any other site. Nothing Electron-specific to configure. ## Bundled apps [Section titled “Bundled apps”](#bundled-apps) Never load via file:// Don’t `win.loadFile("index.html")` and expect tracking to work. A `file://` page’s origin is the opaque string `"null"`, which can never match a trusted domain, so every event is dropped by design. This isn’t a bug to work around; `file://` origins are unsupportable for a domain-locked ingest model. Instead, register a **standard, secure custom protocol scheme** before the app is ready, and load your bundled app through it: main.js ```js const { app, protocol, BrowserWindow } = require("electron"); protocol.registerSchemesAsPrivileged([ { scheme: "app", privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, }, }, ]); app.whenReady().then(() => { // ... register a protocol.handle("app", ...) that serves your bundled files ... const win = new BrowserWindow({ /* ... */ }); win.loadURL("app://myapp/index.html"); }); ``` A page served over `app://myapp/...` has a real, non-opaque origin: `app://myapp`. Add `myapp` as a trusted domain in the dashboard (single-label domains, like `localhost`, are allowed) and point the snippet at it: ```html ``` With that in place, `/ingest`, the widget, and the browser SDK all work exactly as they would on the web. ## Tracking from the main process [Section titled “Tracking from the main process”](#tracking-from-the-main-process) The [`@canarylytics/sdk`](/docs/api/sdk/) client is isomorphic, so in Electron’s main process it behaves like any Node server calling `/v1`. There is no `Origin` header and no CORS check there (that only applies to browser reads), just a Bearer-authed request: main.js ```ts import { createClient } from "@canarylytics/sdk"; const lytics = createClient({ apiKey: process.env.CANARYLYTICS_KEY }); await lytics.events.track("app_launched", { version: app.getVersion() }); ``` ## Related [Section titled “Related”](#related) [Capacitor & Ionic](/docs/environments/capacitor/)The mobile-hybrid equivalent of this page. [Custom events & goals](/docs/tracking/events-and-goals/)Server-side event constraints for main-process tracking. # Framework integrations > Where to put the CanaryLytics script tag in plain HTML, Vite, Next.js App Router, Astro and TanStack Start, plus the pre-init fallback for async script injection. The snippet is a single ` ``` * Vite SPA Add it directly to `index.html`. Vite serves that as static HTML, so the tag reaches the browser unmodified: index.html ```html ``` * Next.js (App Router) app/layout.tsx ```tsx import Script from "next/script"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( ``` * TanStack Start Add it to the root route’s `head` config: src/routes/\_\_root.tsx ```tsx export const Route = createRootRoute({ head: () => ({ // ...meta, links, etc. scripts: [ { src: "https://cdn.analytics.canarycoders.es/lytics.js", defer: true, "data-domain": "myapp.com" }, ], }), // ... }); ``` For any framework not listed here: if it can render a literal ` ``` `data-api` defaults to `https://api.analytics.canarycoders.es`; override it any time you point the tracker at a self-hosted or local API origin. Verify the setup with `GET /v1/validate?domain=...&origin=http://localhost:5173`. ## Related [Section titled “Related”](#related) [Troubleshooting](/docs/troubleshooting/)Reading the /v1/validate response field by field. [Capacitor & Ionic](/docs/environments/capacitor/)Why production hybrid apps share this toggle. # SPAs & client-side routing > CanaryLytics tracks single-page app navigations automatically by patching the history API. Hash-based routers opt in with the data-hash attribute. Single-page apps work out of the box: the snippet patches `history.pushState`/`replaceState` and listens for `popstate`, so every client-side navigation fires a `page_view` with no extra configuration. Only hash-based routers need to opt in. ## History-based routers [Section titled “History-based routers”](#history-based-routers) React Router, TanStack Router, Vue Router (history mode), Next.js, and anything else that uses the browser’s real `history` API work **out of the box**. Every navigation fires a `page_view` for the new `pathname + search`. Consecutive calls to the same path are deduped, so a `replaceState` that doesn’t actually change the path won’t double-count. ```html ``` ## Hash-router SPAs [Section titled “Hash-router SPAs”](#hash-router-spas) Opt-in required Hash-based routing (`/#/about`, common in older Vue Router / Angular / Reach Router setups) is **not tracked by default**. `pathname` doesn’t change on a hash navigation, so without extra configuration every route inside the app looks like the same page. Add the `data-hash` attribute to opt in: ```html ``` With `data-hash` present, two things change: 1. The tracked `path` becomes `pathname + search + hash`, so `/#/about` is reported distinctly from `/#/pricing`. 2. A `hashchange` listener fires a `page_view` on every hash navigation, in addition to the `popstate`/patched-`history` listeners. Without `data-hash`, hash changes are invisible to CanaryLytics, and the app looks like a single-page site with no route breakdown. ## Link clicks [Section titled “Link clicks”](#link-clicks) Plain left-clicks on same-origin `` elements are tracked as `link_click` events (target path only). Modified clicks (`Cmd`/`Ctrl`/`Shift`/`Alt`-click, opening a new tab), `#`/`mailto:`/`tel:`/`javascript:` links, and external links are all deliberately left untracked. ## Related [Section titled “Related”](#related) [Framework integrations](/docs/environments/framework-integrations/)Where the tag goes in Vite, Next.js, Astro, and TanStack Start. [Script reference](/docs/tracking/script-reference/)How SPA patching and dedup work under the hood. # SSR apps > Server-rendered apps (Rails, Django, Express, Next.js Pages Router, Remix) work like static sites. The CanaryLytics snippet runs in the browser after the page loads. Server-rendered pages work exactly like static sites from the tracker’s point of view: the snippet is inert until the browser parses and executes it, so it doesn’t matter whether the surrounding HTML came from a template on disk or was rendered per-request on the server. Include the snippet in whatever shared layout or partial renders your `` (or the end of ``): ```html ``` That is the whole integration for classic server-rendered apps. There is no client-side router to patch and no hydration timing to worry about. If your SSR framework layers a client-side router on top of the initial server render (Next.js, Remix, SvelteKit, Nuxt, and similar “SSR + SPA navigation” frameworks), the snippet’s `history.pushState`/`replaceState` patching picks up client-side route changes automatically once the app hydrates. ## Related [Section titled “Related”](#related) [SPAs & client-side routing](/docs/environments/spas/)How post-hydration navigations are tracked. [Framework integrations](/docs/environments/framework-integrations/)Exactly where the tag goes in specific frameworks. # Static sites > Add CanaryLytics to a plain HTML or statically-generated site with one script tag in your shared layout. No build step, no configuration. Static sites, whether hand-written HTML or the output of a static site generator, are the simplest case and work out of the box. Paste the snippet into your shared layout or template so it lands on every page: ```html My site ... ``` Each full page load fires one `page_view` (path + query string, no hash). Same-origin link clicks are tracked automatically; external links are deliberately left untracked. There is no client-side router to patch, so this is the entire integration. Content-Security-Policy If the site sets a CSP, add the CDN to `script-src` and the API origin to `connect-src`, or the browser silently blocks the request before it is even sent: ```plaintext Content-Security-Policy: script-src 'self' https://cdn.analytics.canarycoders.es; connect-src 'self' https://api.analytics.canarycoders.es; ``` A CSP block never reaches the server, so `GET /v1/validate` can’t see it. See [troubleshooting](/docs/troubleshooting/#content-security-policy). ## Related [Section titled “Related”](#related) [Framework integrations](/docs/environments/framework-integrations/)Where the tag goes in Vite, Next.js, Astro, and TanStack Start. [Script reference](/docs/tracking/script-reference/)Every attribute the snippet reads. # Keys & security > CanaryLytics projects have two kinds of API key. A secret live key (cly_live_…) has full access; an embeddable read-only key (cly_read_…) can only read stats. A CanaryLytics project has exactly one **live key** (`cly_live_…`) with full access, and optionally one **read-only key** (`cly_read_…`) that can read stats but never write or change anything. The live key stays secret; the read-only key is the one that can appear in public HTML. ## Live key vs read-only key [Section titled “Live key vs read-only key”](#live-key-vs-read-only-key) | Capability | `cly_live_<32 hex>` | `cly_read_<32 hex>` | | ---------------------------- | ------------------- | ----------------------- | | Per project | exactly one | zero or one | | Dashboard login | yes | yes (read-only mode) | | `GET /v1/*` reads | yes | yes | | Widget | yes | yes | | `POST /v1/events` | yes | **403 `read_only_key`** | | Dashboard settings mutations | yes | **forbidden** | Never ship the live key Anywhere a page’s source can be viewed (widget embeds, admin panels, browser bundles) use the read-only key, never the live one. The live key can rotate the project’s keys, add or remove trusted domains, and write events. The read key can only read. ## How keys are stored [Section titled “How keys are stored”](#how-keys-are-stored) Only the hex `HMAC-SHA256(key, KEY_HMAC_SECRET)` hash is persisted. The plaintext key is shown exactly once, at creation or rotation, and is never logged. Authenticating a request is a single indexed lookup: hash the Bearer token and match it against the live-key-hash and read-key-hash columns; whichever matched decides whether the request gets `"full"` or `"read"` access. A database dump alone is useless without the separate `KEY_HMAC_SECRET`. Every query the API runs is hard-scoped `WHERE project_id = `. There is no cross-project read path, regardless of which key flavor authenticated the request. ## Rotation [Section titled “Rotation”](#rotation) From the dashboard’s Settings page: * **Rotate key** replaces the live key immediately. The old key stops resolving the moment the new hash is written, with no grace period. The new plaintext is shown once. * **Create/replace read key** mints a `cly_read_…` key. Minting over an existing one revokes the old one, since a project holds at most one read key at a time. * **Revoke read key** removes it entirely. Browser reads that were using it (the widget, SDK clients) stop working immediately; the project still operates on the live key alone. ## Key format [Section titled “Key format”](#key-format) ```plaintext cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0 cly_read_a1b2c3d4e5f60718293a4b5c6d7e8f90 ``` Both are a fixed prefix (`cly_live_` or `cly_read_`) followed by 32 lowercase hex characters (16 random bytes). An `Authorization: Bearer …` header carrying anything else is rejected before it ever reaches a database lookup. ## Related [Section titled “Related”](#related) [Widget embed](/docs/widget/)Where the read-only key earns its keep. [REST API](/docs/api/rest/)How each key authenticates against /v1. # Custom events, goals & UTM tracking > Track custom events with window.lytics.track() in the browser or POST /v1/events from a server, turn them into goal conversions, and capture UTM campaign parameters automatically. Custom events record what automatic tracking misses: signups, checkouts, feature usage. Track them from the browser with `window.lytics.track()`, or from a server with the SDK or `POST /v1/events`; goals then turn event names or URL paths into conversion metrics. ## Track from the browser [Section titled “Track from the browser”](#track-from-the-browser) Once `lytics.js` has loaded, call `window.lytics.track(name, props?)`: ```js window.lytics.track("signup", { plan: "pro" }); ``` | Constraint | Value | | ------------ | ---------------------------------------------------------------------------- | | `name` | Must match `[a-zA-Z0-9_.:-]{1,64}`; truncated to 64 characters | | `props` | Flat object of string/number/boolean values. Nested objects aren’t supported | | `props` size | Serialized to at most 2048 bytes | ### Track before the script has loaded [Section titled “Track before the script has loaded”](#track-before-the-script-has-loaded) If you need to call `track()` before `lytics.js` has finished loading (for example from an inline script that fires early), install this stub first. It queues calls, and the real tracker drains the queue in order right after its own initial pageview: ```html ``` ## Track from a server [Section titled “Track from a server”](#track-from-a-server) Use the [SDK](/docs/api/sdk/) or a raw `POST /v1/events` call with a full (`cly_live_…`) key. Read-only keys get `403 read_only_key`: ```ts import { createClient } from "@canarylytics/sdk"; const lytics = createClient({ apiKey: process.env.CANARYLYTICS_KEY }); await lytics.events.track("signup", { plan: "pro" }, { path: "/app/onboarding" }); ``` ```bash curl https://api.analytics.canarycoders.es/v1/events \ -H "Authorization: Bearer cly_live_…" \ -H "Content-Type: application/json" \ -d '{"name":"signup","props":{"plan":"pro"},"path":"/app/onboarding"}' ``` `path` defaults to `"/"`. `sessionId` is optional and lets you attach the event to an existing client-minted session. Omit it and the server mints a random one. Server-side events skip geo/UA classification entirely: `isBot` is always `false`, and there is no traffic-source label. Note Custom events count against the project’s daily event cap, same as any other event. A `429 daily_cap_exceeded` means today’s cap is reached. ## Goals [Section titled “Goals”](#goals) Goals turn a custom event name or a URL path pattern into a conversion metric. They are created in the dashboard, under Settings then Goals. There is no public REST or SDK endpoint to create one, only to read the resulting stats: * **Event goals** match an exact custom event name. * **Path goals** match an exact path, or a path with a `/*` suffix for prefix matching (for example `/checkout/success/*`). A project can have up to 20 goals. Read conversion counts and rates with `GET /v1/stats/goals` or `client.stats.goals()`. Conversion rate is converted sessions divided by total sessions in the window. ## UTM tracking [Section titled “UTM tracking”](#utm-tracking) For **page\_view** events only, `utm_source`, `utm_medium`, and `utm_campaign` are extracted from the query string of the landing path at ingest time and stored alongside the event. No snippet configuration is needed; it happens automatically for any URL like: ```plaintext https://myapp.com/?utm_source=newsletter&utm_medium=email&utm_campaign=spring-launch ``` `utm_term` and `utm_content` are not captured. Read the breakdown with `GET /v1/stats/utm` or `client.stats.utm()`, which returns sessions and pageviews grouped independently by source, medium, and campaign. ## Related [Section titled “Related”](#related) [Script reference](/docs/tracking/script-reference/)Everything lytics.js tracks automatically. [REST API](/docs/api/rest/)POST /v1/events and GET /v1/stats/goals in full. # Script reference > Every configuration attribute lytics.js reads, what it tracks automatically, its wire format, and how the keyless snippet transports events to /ingest. `lytics.js` is the CanaryLytics tracking snippet: a zero-dependency script (\~2.5 KB min+gzip, budget-enforced in CI) that tracks pageviews, SPA navigations, and link clicks automatically, with no key on the page. It is SSR-safe: it bails out immediately if `window`/`document` aren’t available and does nothing until it runs in a real browser. ```html ``` ## Attributes [Section titled “Attributes”](#attributes) | Attribute | Default | Description | | ------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-domain` | `location.hostname` | Domain reported to `/ingest`. Must match one of the project’s trusted domains or events are silently dropped. | | `data-api` | `https://api.analytics.canarycoders.es` | API origin override. Point this at a local or self-hosted instance. | | `data-hash` | absent (off) | Opt-in flag (presence-only, no value needed). Includes `location.hash` in tracked paths and adds a `hashchange` pageview listener. See [SPAs & client-side routing](/docs/environments/spas/#hash-router-spas). | ## Programmatic config fallback [Section titled “Programmatic config fallback”](#programmatic-config-fallback) When the script is bundled into your own JS instead of loaded via a literal ` ``` Precedence runs data attribute, then pre-init global, then built-in default. A `data-domain` attribute always wins over `window.lytics.domain` when both are present. ## What automatic tracking covers [Section titled “What automatic tracking covers”](#what-automatic-tracking-covers) The initial pageview fires once on load, with `path = pathname + search` (`+ hash` only when `data-hash` is set). For SPA navigations the tracker patches `history.pushState` and `replaceState` and listens for `popstate`, so client-side route changes fire pageviews too. Consecutive calls to the same path are deduped. Plain left-clicks on same-origin `` elements fire a `link_click` event carrying the target path. Modified clicks, `#`/`mailto:`/`tel:`/`javascript:` hrefs and external links are left untracked. Referrers are split by origin: a same-origin referrer contributes its path, a cross-origin one contributes only its hostname, never the path or query, so search terms never leave the browser. Both feed traffic-source classification and are then handled per the [privacy stance](/docs/#privacy-stance). Tip Need custom events, goal conversions, or UTM capture? See [Custom events, goals & UTM tracking](/docs/tracking/events-and-goals/). ## Transport [Section titled “Transport”](#transport) `lytics.js` sends events via `fetch` with `keepalive: true`, `credentials: "omit"`, and a `text/plain` body. That combination keeps it a CORS “simple request” (no preflight) and survives page unloads. `credentials: "omit"` matters: `/ingest` answers with a wildcard `Access-Control-Allow-Origin: *`, and browsers reject credentialed cross-origin requests against a wildcard ACAO. `navigator.sendBeacon` is used only as a fallback if `fetch` throws. Note A keepalive request fired during navigation can show a duplicate `ERR_ABORTED` row in Chrome DevTools. That is a DevTools display artifact. The event landed once, which you can verify in the dashboard or via `/v1/stats`. ## Wire format [Section titled “Wire format”](#wire-format) Every event is a `POST /ingest` with `Content-Type: text/plain` and a JSON body: ```json { "domain": "myapp.com", "sessionId": "", "eventType": "page_view | link_click | custom", "path": "/pricing", "locale": "en-US", "referrerHost": "t.co", "referrerPath": "/old-page", "targetPath": "/next-page", "eventName": "signup", "props": { "plan": "pro" } } ``` `locale`, `referrerHost` (cross-origin hostname only), and `referrerPath` (same-origin SPA navigations) are optional; `targetPath` appears only on `link_click`; `eventName` and `props` (flat string/number/boolean values, ≤ 2 KB) only on `custom`. The response is always `204`, because invalid or untrusted events are dropped silently by design (an error would be an oracle for attackers probing other projects). Debug your own setup with [`GET /v1/validate`](/docs/troubleshooting/) instead. ## Session id [Section titled “Session id”](#session-id) Each browser tab gets a random UUID (`crypto.randomUUID()`, with an RFC 4122 v4 fallback) stored in `sessionStorage["cly_sid"]`. It is tab-scoped: not shared across tabs, and not persisted once a tab closes. There is no cookie and no cross-session identifier. If `sessionStorage` or `crypto` throw (for example in a locked-down embed context), the tracker simply doesn’t send that event rather than falling back to something less private. ## Related [Section titled “Related”](#related) [Custom events & goals](/docs/tracking/events-and-goals/)window\.lytics.track(), the early-call queue, and goals. [Troubleshooting](/docs/troubleshooting/)Events not showing up? Start with /v1/validate. # Troubleshooting > When CanaryLytics shows no data, start with GET /v1/validate. It dry-runs the exact ingest acceptance rules and explains every silent drop your key is allowed to see. `POST /ingest` always answers `204`, whether an event was accepted or dropped, so there is no error to read in the Network tab. Start every “no data showing up” investigation with `GET /v1/validate`. ## Common silent-drop causes, ranked [Section titled “Common silent-drop causes, ranked”](#common-silent-drop-causes-ranked) 1. `data-domain` doesn’t match any trusted domain (typo, or the domain was never added). 2. Page origin doesn’t match a trusted domain. Usually a `www.` vs bare-domain mismatch, a staging subdomain nobody added, or [`file://`/opaque origins](/docs/environments/electron/#bundled-apps). 3. Local development without the project’s “Allow local origins” toggle or an explicit `localhost` domain. See [Localhost & development](/docs/environments/localhost/). 4. A Content-Security-Policy blocking the script or the API origin. 5. An adblocker blocking `cdn.analytics.canarycoders.es` or `api.analytics.canarycoders.es`. 6. The project’s daily event cap has been reached. Causes 1 to 3 are exactly what `/v1/validate` catches. Causes 4 to 6 never reach the server, so see [what /v1/validate can’t see](#what-v1validate-cant-see). ## Walking through /v1/validate [Section titled “Walking through /v1/validate”](#walking-through-v1validate) Call it with the *exact* domain/origin pair a real visitor’s browser would send. Pull them off the actual page (`location.hostname` for domain, `location.origin` for origin) rather than guessing: ```bash curl "https://api.analytics.canarycoders.es/v1/validate?domain=myapp.com&origin=https://staging.myapp.com" \ -H "Authorization: Bearer cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0" ``` ```json { "domain": "myapp.com", "origin": "https://staging.myapp.com", "wouldAccept": true, "checks": { "domainOwnedByProject": true, "originPresent": true, "originParseable": true, "originIsLocal": false, "allowLocalOrigins": false, "originTrusted": true }, "reasons": [] } ``` Each `checks` field mirrors one step of the real `/ingest` logic: | Field | Fails when | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `domainOwnedByProject` | `domain` doesn’t match any of *this* project’s trusted domains. Matching is on label boundaries, so `evil-myapp.com` never matches `myapp.com`. | | `originPresent` | No `origin` was supplied to the check. This mirrors a request with no `Origin` header, which is always dropped. | | `originParseable` | `origin` isn’t a valid URL (it should look like `https://app.example.com`). | | `originIsLocal` | Informational only. `true` when the origin’s host is `localhost`, `127.0.0.1`, or `*.localhost`. | | `originTrusted` | The origin’s host doesn’t pass the trust rule for its kind: local origins need `allowLocalOrigins` (or `localhost` explicitly trusted); everything else needs a label-boundary match against a trusted domain. | `wouldAccept` is `domainOwnedByProject && originTrusted`. When it’s `false`, `reasons` spells out which checks failed in plain English, so you don’t have to reverse-engineer the table by hand. Why this can't leak other projects' domains `domainOwnedByProject: false` looks identical whether `domain` is malformed, unclaimed, or owned by someone else’s project entirely. That is deliberate. The endpoint is key-authed and safe to expose precisely because it can never be used to probe whether a domain belongs to another project. ## What /v1/validate can’t see [Section titled “What /v1/validate can’t see”](#what-v1validate-cant-see) It is a pure server-side check of the domain and Origin acceptance rules. It cannot tell you whether the request ever reached the server in the first place. ### Content-Security-Policy [Section titled “Content-Security-Policy”](#content-security-policy) A CSP that doesn’t allow the snippet or the API blocks the request in the browser before it is sent, so nothing shows up server-side to validate against. Add both origins: ```plaintext Content-Security-Policy: script-src 'self' https://cdn.analytics.canarycoders.es; connect-src 'self' https://api.analytics.canarycoders.es; ``` ### Adblockers [Section titled “Adblockers”](#adblockers) Ad and tracker blockers may block `lytics.js` itself or the `/ingest` request by URL pattern. This is also invisible to `/v1/validate`, since the check runs against a hypothetical request you provide rather than observing what actually left a real visitor’s browser. ### The daily event cap [Section titled “The daily event cap”](#the-daily-event-cap) Once a project’s daily event count exceeds its cap, further events are dropped silently: same `204`, no signal to the client. Check current usage against the cap on the dashboard’s Settings page. This one isn’t exposed via `/v1` or the SDK, only the dashboard UI. ## Still stuck? [Section titled “Still stuck?”](#still-stuck) Confirm `GET /v1/validate` with the exact domain/origin pair a real visitor’s browser would send. A mismatch there is the only failure mode this endpoint is guaranteed to catch. Everything else on this page fails client-side, before the server ever sees a request. ## Related [Section titled “Related”](#related) [How it works](/docs/how-it-works/#silent-drops-why-ingest-never-returns-an-error)Why /ingest never returns an error, and the no-oracle design behind it. [Localhost & development](/docs/environments/localhost/)Getting events flowing from local dev servers. # Widget embed > The web component embeds live CanaryLytics stats in any page on a trusted domain. It runs on the read-only key and is themeable with CSS custom properties. `` is a self-contained custom element (Shadow DOM, Preact-rendered, shipped as a single-file IIFE bundle) that renders live stats for your project inside any page you control. Drop it into any page whose origin is one of the project’s trusted domains: ```html ``` Use the read-only key Anyone who views source on the host page can read the `api-key` attribute. Always use a `cly_read_…` key here. It can only read stats, never rotate keys, change settings or write events. See [Keys & security](/docs/keys/). ## Attributes [Section titled “Attributes”](#attributes) | Attribute | Default | Description | | ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `api-key` | required | A `cly_read_…` key. Without it the widget renders an inline error instead of data. | | `base-url` | `https://api.analytics.canarycoders.es` | API origin override (dev/self-host). | | `window` | `7d` | One of `24h`, `7d`, `30d`, `90d`. | | `panels` | `stats,chart,sources,countries` | Comma-separated subset of `stats`, `chart`, `sources`, `countries`, `pages`, `events`, `live`, in the order you want them rendered. Unknown values are dropped; an empty or invalid list falls back to the default. | | `theme` | `auto` | `light`, `dark`, or `auto` (follows `prefers-color-scheme` and updates live if it changes). | | `refresh` | `10` | Poll interval in seconds. `0` disables polling (fetch once). | Attributes are reactive: changing any of them on the element re-renders the widget without a page reload. ## Why it needs a trusted origin [Section titled “Why it needs a trusted origin”](#why-it-needs-a-trusted-origin) The widget calls the same `/v1` REST endpoints as the SDK, from the browser, using the `api-key` attribute as its Bearer token. `/v1`’s CORS layer only reflects `Access-Control-Allow-Origin` for the authenticated project’s trusted domains (or a dashboard/localhost-with-toggle origin), so embedding the widget on an untrusted page fails the same way any other cross-origin fetch would, even with a valid key. ## Theming [Section titled “Theming”](#theming) Everything is scoped under a `.cly` class inside the shadow root, so nothing leaks in from the host page’s styles, or out to them. Look and feel is controlled entirely through CSS custom properties, which **do** cross the shadow boundary (they are inherited properties). Set them on the `canary-lytics` element itself, or any ancestor, from the host page’s own stylesheet: ```css canary-lytics { --cly-accent: #7c3aed; --cly-border: #d4d4d8; } ``` | Property | Light default | Dark default | Used for | | ------------------- | --------------------- | --------------------- | ----------------------------------------------------- | | `--cly-bg` | `#ffffff` | `#101013` | Widget background | | `--cly-card` | `#f8f8f8` | `#18181b` | Panel/card background | | `--cly-fg` | `#18181b` | `#f4f4f5` | Primary text | | `--cly-muted` | `#6b7280` | `#9ca3af` | Secondary/label text | | `--cly-border` | `#e4e4e7` | `#27272a` | Card and container borders | | `--cly-accent` | `#b45309` | `#fb7185` | Flash-on-increment stat color | | `--cly-accent-soft` | `rgba(244,63,94,.18)` | `rgba(244,63,94,.22)` | Flash-on-increment background | | `--cly-bar` | `rgba(244,63,94,.22)` | `rgba(244,63,94,.26)` | Ranked-list row bars (sources/countries/pages/events) | | `--cly-line` | `#e11d48` | `#e11d48` | Sparkline line color | | `--cly-area` | `rgba(244,63,94,.14)` | `rgba(244,63,94,.18)` | Sparkline fill | | `--cly-live` | `#16a34a` | `#22c55e` | Live-visitors pulse dot | | `--cly-live-glow` | `rgba(22,163,74,.45)` | `rgba(34,197,94,.45)` | Live-visitors pulse glow | | `--cly-danger` | `#dc2626` | `#f87171` | Error text | Panel-specific notes: `stats` renders session/pageview/bounce tiles that briefly flash `--cly-accent-soft` on increment; `chart` is an SVG sparkline over the timeseries; `live` replaces the header’s window label with a pulsing current-visitors badge instead of occupying a panel slot. ## Related [Section titled “Related”](#related) [Keys & security](/docs/keys/)Why the read-only key is safe to embed. [REST API](/docs/api/rest/)The /v1 endpoints the widget calls under the hood.