Skip to content

REST API

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.

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, locales, 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/import live only Backfills historical events, per-row accounting
POST /v1/events live only 202, records a server-side custom event
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.

Errors are always { "error": { "code": "...", "message": "..." } }, with the HTTP status carrying the usual meaning (400, 401, 403, 404, 429).

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).

/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.

POST /v1/projects

The one unauthenticated route. It is gated by an invite code instead of a key, since no key exists yet.

Terminal window
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.

GET /v1/project

Returns ProjectInfo: id, name, keyPrefix, domains, retentionDays, dailyEventCap, createdAt, readKeyPrefix (null if none minted), allowLocalOrigins.

GET /v1/validate?domain=myapp.com&origin=https://myapp.com

Dry-runs the /ingest acceptance rules for the authenticated project. See the troubleshooting guide for a field-by-field walkthrough.

All GET /v1/stats/* routes take ?window=24h|7d|30d|90d (default 7d) and, except realtime, two class filters that both default to false: ?includeBots=true|1 and ?includeInternal=true|1 (self-declared internal traffic):

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 optional; omit it for all countries)
GET /v1/stats/locales GET /v1/stats/events
GET /v1/stats/utm GET /v1/stats/devices
GET /v1/stats/goals GET /v1/stats/realtime
Terminal window
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, non-internal sessions with at least one event in the last 5 minutes, and it ignores window entirely. Bots and internal traffic are always excluded (a crawler or a teammate is never a “current visitor”), which is why it has no includeBots/includeInternal toggles.

/v1/stats/overview also reports segments, { humans, bots, vpn, internal } distinct-session counts, always computed over ALL traffic in the window so the toggles never hide how much bot or internal traffic there is. Each entry in bySource (and /v1/stats/sources) carries a display label and a channel (search | social | direct | referral) alongside the raw source id, so clients can render the breakdown without shipping their own copy of the source catalog.

/v1/stats/locales breaks sessions down by the locale the snippet reports (the page’s lang attribute, falling back to the browser language). Tags are grouped case-insensitively and returned lowercased; null is the unknown bucket.

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.

GET /v1/sessions?window=7d&limit=50&offset=0&country=ES&regionCode=ES-MD&includeBots=false&includeInternal=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.

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).

Terminal window
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, isInternal. New columns are only ever appended at the end, so positional consumers keep working.

POST /v1/import

The write twin of /v1/export, for backfilling historical events: a migration from another analytics store, or another CanaryLytics project’s export. Full key only (read keys get 403 read_only_key). The body is either JSON { "events": [...] } or NDJSON with one event object per line (Content-Type: application/x-ndjson), so an NDJSON export round-trips directly:

Terminal window
curl -X POST "https://api.analytics.canarycoders.es/v1/import" \
-H "Authorization: Bearer cly_live_..." \
-H "Content-Type: application/x-ndjson" \
--data-binary @events.ndjson

Each event needs createdAt (no future timestamps, beyond a 60-second clock-skew allowance), sessionId (UUID), eventType and path; every other export column is optional. Omit browser/os/deviceType and they are derived from userAgent at import time, with the same classifier live ingest uses. At most 5000 events per request; batch larger migrations.

The response reports exactly what happened: { received, inserted, skippedInvalid, skippedDuplicate }. Rows may carry their own id: inserts are ON CONFLICT DO NOTHING, so re-running a batch skips what already landed instead of double-counting.

Two deliberate differences from live ingest: imports bypass the daily event cap (it protects live ingest from runaway snippets, not deliberate migrations), and rows older than the project’s retention window are still accepted, but the nightly retention job deletes them. Raise retentionDays in Settings before importing deep history.

POST /v1/events

Full key only. Read keys get 403 read_only_key.

Terminal window
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 for the field constraints.