Skip to content

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.

Two modes, both via Authorization: Bearer <…>:

  • adminBearer $ADMIN_TOKEN. Cross-tenant operator access.
  • apiKeyBearer <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.

MethodPathAuth
GET/healthznone
GET/workspacesadmin
POST/workspacesadmin
GET/workspaces/:slugadmin
PATCH/workspaces/:slugadmin
GET/workspaces/:slug/sitesadmin
POST/workspaces/:slug/sitesadmin
DELETE/workspaces/:slug/sites/:idadmin
GET/workspaces/:slug/keysadmin or admin-scoped api_key
POST/workspaces/:slug/keysadmin or admin-scoped api_key
DELETE/workspaces/:slug/keys/:keyIdadmin or admin-scoped api_key
GET/workspaces/:slug/auditadmin or admin-scoped api_key
GET/sites/:siteId/eventsadmin or query:read api_key
GET/sites/:siteId/pageviewsadmin or query:read api_key
GET/sites/:siteId/sessionsadmin or query:read api_key
GET/sites/:siteId/users/:userId/timelineadmin or query:read api_key
POST/sites/:siteId/sourcemapssite_id bearer (legacy)
GET/sites/:siteId/dlqadmin or admin-scoped api_key
POST/sites/:siteId/dlq/:eventId/replayadmin or admin-scoped api_key
POST/sites/:siteId/gdpr/exportadmin or admin-scoped api_key
POST/sites/:siteId/gdpr/deleteadmin or admin-scoped api_key
POST/webhooks/:adapter/:siteIdper-adapter signature

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.

{
"count": 12345,
"by_day": [{ "date": "2026-04-01", "count": 412 }]
}

400 invalid_date_param when missing/malformed; 400 invalid_date_range when from > to.

{
"by_name": [{ "name": "pageview", "count": 12345 }]
}

Sorted by count desc, then name asc.

{
"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.

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

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
}

Re-POST the row’s payload to the collector’s /events.

  • 200 { id, replayed_at } on collector 202. The DLQ row is deleted.
  • 502 collector_rejected on any other collector status. The DLQ row stays in place.
  • 502 collector_unreachable on 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.

See Workspaces + API keys for the lifecycle walkthrough. The CRUD endpoints follow REST conventions and the same error envelope as everything else.

See Webhooks.

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