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.
The key is the account
Section titled “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 for the full comparison.
Trusted domains
Section titled “Trusted domains”Every project owns an allowlist of domains. Two independent checks use it:
- The tracking snippet carries no key. It reports a
domain(fromdata-domain, defaulting tolocation.hostname). The ingest endpoint resolves the project from that domain, which works because domains are globally unique: a domain belongs to exactly one project. - The request’s
Originheader must also match a trusted domain, or the event is dropped. This is what stops someone copying yourdata-domainvalue 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).
What gets stored
Section titled “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 |