Query API endpoints
The query API is a Hono service on 0.0.0.0:8081 by default. All
endpoints accept and return JSON. The full canonical contract — every
header, every error envelope — lives in the in-tree
docs/API_CONTRACT.md.
This page is a developer-friendly summary; the contract is the
authority.
Authentication
Section titled “Authentication”Two modes, both via Authorization: Bearer <…>:
admin—Bearer $ADMIN_TOKEN. Cross-tenant operator access.apiKey—Bearer <plaintext_key>. Workspace-scoped, with one of:ingest:write,query:read,admin.
Cross-workspace probes by a non-admin actor return 404 with the same
envelope as “not found” — the platform never confirms the existence of a
foreign workspace through status-code differential.
Endpoint table
Section titled “Endpoint table”| Method | Path | Auth |
|---|---|---|
GET | /healthz | none |
GET | /workspaces | admin |
POST | /workspaces | admin |
GET | /workspaces/:slug | admin |
PATCH | /workspaces/:slug | admin |
GET | /workspaces/:slug/sites | admin |
POST | /workspaces/:slug/sites | admin |
DELETE | /workspaces/:slug/sites/:id | admin |
GET | /workspaces/:slug/keys | admin or admin-scoped api_key |
POST | /workspaces/:slug/keys | admin or admin-scoped api_key |
DELETE | /workspaces/:slug/keys/:keyId | admin or admin-scoped api_key |
GET | /workspaces/:slug/audit | admin or admin-scoped api_key |
GET | /sites/:siteId/events | admin or query:read api_key |
GET | /sites/:siteId/pageviews | admin or query:read api_key |
GET | /sites/:siteId/sessions | admin or query:read api_key |
GET | /sites/:siteId/users/:userId/timeline | admin or query:read api_key |
POST | /sites/:siteId/sourcemaps | site_id bearer (legacy) |
GET | /sites/:siteId/dlq | admin or admin-scoped api_key |
POST | /sites/:siteId/dlq/:eventId/replay | admin or admin-scoped api_key |
POST | /sites/:siteId/gdpr/export | admin or admin-scoped api_key |
POST | /sites/:siteId/gdpr/delete | admin or admin-scoped api_key |
POST | /webhooks/:adapter/:siteId | per-adapter signature |
Analytics
Section titled “Analytics”All analytics endpoints sit under /sites/:siteId/. The workspace
middleware short-circuits with 404 when the site is not in sites.
from / to are YYYY-MM-DD and inclusive on both ends.
GET /sites/:siteId/pageviews?from=&to=
Section titled “GET /sites/:siteId/pageviews?from=&to=”{ "count": 12345, "by_day": [{ "date": "2026-04-01", "count": 412 }]}400 invalid_date_param when missing/malformed; 400 invalid_date_range
when from > to.
GET /sites/:siteId/events?from=&to=
Section titled “GET /sites/:siteId/events?from=&to=”{ "by_name": [{ "name": "pageview", "count": 12345 }]}Sorted by count desc, then name asc.
GET /sites/:siteId/sessions?from=&to=
Section titled “GET /sites/:siteId/sessions?from=&to=”{ "count": 1024, "avg_duration_s": 187.4, "avg_events": 4.2, "bounce_rate": 0.314}Empty range yields all zeros (never null or NaN).
GET /sites/:siteId/users/:userId/timeline?limit=&before=
Section titled “GET /sites/:siteId/users/:userId/timeline?limit=&before=”Per-user event timeline. JOINs events with identity_links so a row
appears for every anon ever bound to the user (cross-device merge).
{ "events": [ { "id": "<uuid>", "type": "<event-type>", "url": "…" | null, "ts": "<iso>", "anon_id": "…", "referrer": "…" | null, "country": "NL" | null, "city": "Amsterdam" | null, "browser": "Chrome" | null, "os": "macOS" | null, "device_type": "desktop" | "mobile" | "tablet" | "other" | null } ], "next_before": "<iso>" | null}404 user_not_found when no identity_links row matches (site_id, user_id).
events.raw JSONB and the parsed ua string are deliberately omitted
from the wire shape — they may carry PII the operator never intended to
expose at a per-user surface.
Sourcemaps
Section titled “Sourcemaps”POST /sites/:siteId/sourcemaps
Section titled “POST /sites/:siteId/sourcemaps”Sourcemap upload. Auth: Authorization: Bearer <site_id> (legacy bearer
scheme). Cross-tenant attempts return 401, NOT 403, so a probing
caller cannot confirm a foreign site_id exists.
// request{ "release": "<git-sha>", "file": "<basename.js>", "map": "<base64>" }
// 201{ "size": 12345 }Body cap: 25 MB decoded. Idempotent upsert on (site_id, release, file).
GET /sites/:siteId/dlq?limit=&cursor=
Section titled “GET /sites/:siteId/dlq?limit=&cursor=”Cursor-paginated DLQ rows.
{ "events": [ { "id": "<uuid>", "site_id": "site_marketing", "payload": { /* original wire JSON */ }, "error": [{ "path": "/total_cents", "message": "must be >= 0" }], "received_at": "<iso>" } ], "next_cursor": "<iso>" | null}POST /sites/:siteId/dlq/:eventId/replay
Section titled “POST /sites/:siteId/dlq/:eventId/replay”Re-POST the row’s payload to the collector’s /events.
200 { id, replayed_at }on collector202. The DLQ row is deleted.502 collector_rejectedon any other collector status. The DLQ row stays in place.502 collector_unreachableon network error.
Path A by design (re-enter the validator) so consent / rate-limit / dedup gates are re-evaluated.
See GDPR endpoints for the full request / response
walkthrough. Both export and delete are admin or admin-scoped
api_key.
See Audit log. Cursor-paginated read at
GET /workspaces/:slug/audit.
Workspaces, sites, keys
Section titled “Workspaces, sites, keys”See Workspaces + API keys for the lifecycle walkthrough. The CRUD endpoints follow REST conventions and the same error envelope as everything else.
Webhooks
Section titled “Webhooks”See Webhooks.
Error envelope
Section titled “Error envelope”{ "error": { "code": "<stable_string>", "message": "<human-readable>", "details": [/* optional */] }}code is part of the contract. message may evolve. details carries
structured per-field errors (e.g. schema validation failures).