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 (
{children}