---
title: "REST API"
description: "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."
last_updated: "2026-07-30"
---

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

## 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

```
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

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

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

```
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

```
GET /v1/project
```

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

## Validate

```
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

All `GET /v1/stats/*` routes take `?window=24h|7d|30d|90d` (default `7d`) and, except `realtime`, `?includeBots=true|1` (default `false`):

```
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

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

```
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

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

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

## Related

<CardGrid>
  <LinkCard title="SDK" description="The typed @canarylytics/sdk client for these endpoints." href="/docs/api/sdk/" />
  <LinkCard title="MCP server" description="The same stats as MCP tools for any MCP-capable agent." href="/docs/api/mcp/" />
  <LinkCard title="Keys & security" description="Which key to use where: live vs read-only." href="/docs/keys/" />
</CardGrid>

## Sitemap

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