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
| Field | Required | Limits | Description |
|---|---|---|---|
| s | yes | — | Site ID, e.g. canopy_example_com. |
| n | yes | max 64 chars | Event name: pageview, outbound, or custom. |
| u | no | max 2048 chars | Full page URL. |
| p | no | max 2048 chars | Path including query, e.g. /pricing?plan=pro. |
| r | no | max 2048 chars | Referrer URL. |
| t | no | max 512 chars | Page title. |
| w | no | number | Viewport width in pixels. |
| props | no | any JSON | Custom properties, stored as JSONB. |
| sid | no | max 64 chars | Session ID. Generated server-side if omitted. |
| v | no | — | Tracker version. Validated, not stored. |
| ts | no | number | Client timestamp in ms. Validated, not stored. |
Responses
| Status | Body | When |
|---|---|---|
| 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. |
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
| Param | Values | Description |
|---|---|---|
| siteId | required | Site to query. |
| range | 1h 24h 7d 30d 90d | Time window. Default 7d. The time series bucket size follows the range: minute for 1h, hour for 24h/7d, day for 30d/90d. |
| eventName | string | Filter by exact event name where supported. |
| limit / offset | number | Pagination 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).
GET /api/analytics/overview?siteId=canopy_example_com&range=7d
{
"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:
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:
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:
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:
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:
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:
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 /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:
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:
| Status | Meaning |
|---|---|
| 400 | Malformed request — invalid JSON, failed validation, or missing siteId. |
| 401 | No dashboard session on an analytics, sites, or verify route. |
| 404 | Unknown site ID on ingestion (production), or a site you have no access to. |
| 500 | Storage or query failure — check server logs. |