HTTP API

Canopy is two small APIs. The ingestion endpoint accepts events from the browser tracker, your server, or anything that can POST JSON — no auth, first-party by design. The analytics endpoints are what the dashboard itself calls: session-authenticated reads scoped to your workspace, so anything you can see in the dashboard you can also query programmatically.

1Ingest events

POST /api/canopy/event accepts a single event per request. There are no API keys — the endpoint is meant to be called from your own site or your own backend, and the server enriches every event (session handling, IP hashing, country) before storing it.

POST /api/canopy/event
Content-Type: application/json

{
  "s": "canopy_example_com",
  "n": "pageview",
  "u": "https://example.com/pricing",
  "p": "/pricing",
  "r": "https://google.com",
  "t": "Pricing — Example",
  "w": 1440,
  "props": { "utm_source": "newsletter" },
  "sid": "3f9c2a1e-...",
  "v": "0.1.0",
  "ts": 1739755562410
}

Fields

FieldRequiredLimitsDescription
syes—Site ID, e.g. canopy_example_com.
nyesmax 64 charsEvent name: pageview, outbound, or custom.
unomax 2048 charsFull page URL.
pnomax 2048 charsPath including query, e.g. /pricing?plan=pro.
rnomax 2048 charsReferrer URL.
tnomax 512 charsPage title.
wnonumberViewport width in pixels.
propsnoany JSONCustom properties, stored as JSONB.
sidnomax 64 charsSession ID. Generated server-side if omitted.
vno—Tracker version. Validated, not stored.
tsnonumberClient timestamp in ms. Validated, not stored.

Responses

StatusBodyWhen
202(empty)Event accepted and stored.
400{ "error": "invalid json" }Body is not parseable JSON.
400{ "error": "invalid payload", "issues": … }A field failed validation — issues lists what.
403{ "error": "origin not allowed" }Request origin does not match the site’s registered domain (production only).
403{ "error": "site not verified" }Production ingestion requires a verified domain — add the TXT record and verify in Settings (production only).
404{ "error": "unknown site" }Site ID does not exist (production only — see note below).
500{ "error": "store failed" }Database insert failed.
development shortcut
Outside production, events for unknown site IDs auto-create the site so you can test before signing up. In production, register the site first or ingestion returns 404.

CORS

Cross-origin POSTs are only allowed from the site’s registered domain (including subdomains), validated server-side against the request’s Origin or Referer header, and mirrored back in Access-Control-Allow-Origin with 24-hour preflight caching. That is what lets the tracker live on one host while events post to another — still your infrastructure, no third party involved. Events from any other origin are rejected with 403.

Abuse protections

Production ingestion requires the site's domain to be verified, and events are rate-limited per site and per visitor IP. Events that exceed the limits are dropped silently — the endpoint still answers 202 — and the dashboard shows a verification prompt for unverified sites.

Server-side enrichment

Before an event is stored, the server hashes the visitor IP with SHA-256 and a server-side salt — only the first 16 hex characters are kept, and the raw IP is never written. Country is derived from CDN headers when present (x-vercel-ip-country on Vercel, cf-ipcountry on Cloudflare). The full privacy model is documented in Privacy & compliance.

2Analytics API

All analytics endpoints live under /api/analytics/*, require a signed-in dashboard session (cookie auth), and are scoped to sites in your workspace — requesting a site you don't belong to is rejected. Missing parameters return 400; no session returns 401.

Shared parameters

ParamValuesDescription
siteIdrequiredSite to query.
range1h 24h 7d 30d 90dTime window. Default 7d. The time series bucket size follows the range: minute for 1h, hour for 24h/7d, day for 30d/90d.
eventNamestringFilter by exact event name where supported.
limit / offsetnumberPagination on list-style endpoints.

Overview — GET /api/analytics/overview

The numbers behind the dashboard overview: totals, bounce rate, session duration, top lists, and a time series in one call. Accepts extra filters — eventName, path (substring match), referrer (substring match), country (exact).

request
GET /api/analytics/overview?siteId=canopy_example_com&range=7d
200 OK
{
  "totalEvents": 1284,
  "totalPageviews": 1102,
  "uniqueVisitors": 389,
  "bounceRate": 42.1,
  "avgSessionDuration": 94500,
  "topPages":     [{ "key": "/", "count": 512, "percentage": 46.5 }],
  "topReferrers": [{ "key": "news.ycombinator.com", "count": 210, "percentage": 16.4 }],
  "topCountries": [{ "key": "US", "count": 402, "percentage": 31.3 }],
  "timeSeries":   [{ "timestamp": "…", "value": 42, "label": "Sep 28, 22:00" }]
}

bounceRate is the percentage of sessions with exactly one pageview, and avgSessionDuration is in milliseconds.

Time series — GET /api/analytics/timeseries

Chart-ready buckets with an explicit interval override:

request + response
GET /api/analytics/timeseries?siteId=canopy_example_com&range=7d&interval=hour

{ "data": [{ "timestamp": "…", "value": 42, "label": "Sep 28, 22:00" }] }

interval accepts minute, hour, or day (default hour). Pass eventName to chart a custom event instead of pageviews.

Top lists

Three endpoints share the same shape — pages, referrers, and countries, ordered by count with percentage of total:

requests
GET /api/analytics/top-pages?siteId=…&range=7d&limit=20&eventName=pageview
GET /api/analytics/top-referrers?siteId=…&range=7d
GET /api/analytics/top-countries?siteId=…&range=7d

{ "data": [{ "key": "/pricing", "count": 512, "percentage": 46.5 }], "total": 1102 }

limit defaults to 20 rows. On top-pages, eventName defaults to pageview — change it to rank any custom event by path. Referrer and country lists exclude empty values.

Realtime — GET /api/analytics/realtime

Live visitor activity over a rolling window, in seconds:

request + response
GET /api/analytics/realtime?siteId=canopy_example_com&window=60

{
  "activeVisitors": 7,
  "pageviewsPerMinute": 3.5,
  "eventsPerMinute": 5.2,
  "topActivePages": [{ "key": "/", "count": 4 }],
  "recentEvents": [{ "id": "evt_…", "name": "pageview", "path": "/", "country": "US", "timestamp": "…" }]
}

window defaults to 60 seconds. recentEvents returns the 20 most recent events with country attached.

Event log — GET /api/analytics/events

Paginated raw events for the range:

request + response
GET /api/analytics/events?siteId=…&range=7d&eventName=signup&limit=100&offset=0

{
  "events": [{ "id": "evt_…", "name": "signup", "url": "…", "path": "/", "referrer": "…",
               "title": "…", "viewportW": 1440, "props": { … }, "sessionId": "…",
               "country": "US", "createdAt": "2026-10-17T09:14:22.410Z" }],
  "total": 240, "limit": 100, "offset": 0
}

Export — GET /api/analytics/export

Download the same data as CSV or JSON, streamed as a file attachment:

request
GET /api/analytics/export?siteId=canopy_example_com&range=7d&format=csv

format is csv (default) or json. limit defaults to 10,000 rows and is capped at 50,000. Files are named canopy_<siteId>_<range>.csv. Because the export contains session IDs and property payloads, treat exported files with the same care as your database.

3Manage sites

Sites belong to workspaces. GET /api/sites returns the sites you can access (up to 50, newest first) as a JSON array. To create one, POST the domain as form data — Canopy normalizes it, derives the site ID, and attaches it to your active workspace:

create site
POST /api/sites
Content-Type: application/x-www-form-urlencoded

domain=example.com

→ 200 { "id": "canopy_example_com", "domain": "example.com" }

Domains must contain a dot; protocols, www., and paths are stripped before the site ID is derived. Invalid domains return 400.

4Verify domain ownership

Verification proves you control a domain via a DNS TXT record. Request a record, publish it, then ask Canopy to check — verification happens through DNS-over-HTTPS, so no propagation-wait guessing games.

get record
GET /api/verify?site=canopy_example_com

{ "status": "pending", "record": "canopy-site=9f2c…", "domain": "example.com" }

Add a TXT record at your domain's apex with the returned record value, then trigger the check:

check
POST /api/verify?site=canopy_example_com

→ { "status": "verified", "domain": "example.com" }
→ { "status": "pending",  "record": "canopy-site=9f2c…", "domain": "example.com" }

Once verified, the checkmark appears on the site in your dashboard. Requests for sites you don't have access to return 404, and unknown sites return a not_found status.

5Errors

Error responses are JSON with an error key. The codes you can encounter:

StatusMeaning
400Malformed request — invalid JSON, failed validation, or missing siteId.
401No dashboard session on an analytics, sites, or verify route.
404Unknown site ID on ingestion (production), or a site you have no access to.
500Storage or query failure — check server logs.

Start sending events

No API keys for ingestion. Just POST to your own endpoint.