---
title: "How it works"
description: "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."
last_updated: "2026-07-30"
---

# 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

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

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

<Aside type="caution" title="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.
</Aside>

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

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

<CardGrid>
  <LinkCard title="Keys & security" description="Live vs read-only keys, storage, and rotation." href="/docs/keys/" />
  <LinkCard title="Troubleshooting" description="Debug silent drops with GET /v1/validate." href="/docs/troubleshooting/" />
</CardGrid>

## Sitemap

- [Docs sitemap](https://analytics.canarycoders.es/docs/sitemap.md)
- [Full documentation as a single file](https://analytics.canarycoders.es/llms-full.txt)
