Skip to content

How it works

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.

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 for the full comparison.

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”

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 walks through it field by field, including the failure modes it can’t see (CSP, adblockers, and the daily event cap).

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