Query API endpoints
The query API lives at api.leadmaps.nl. All endpoints accept and return
JSON. This page documents every header, status code, and error envelope you
will deal with as a caller.
Authentication
Section titled “Authentication”Two modes, both via Authorization: Bearer <...>:
admin- an admin token for cross-workspace operator access.apiKey- a workspace API key, with one of the scopes:ingest:write,query:read,admin.
A non-admin caller probing a workspace it has no access to gets a 404 with
the same envelope as “not found”. leadmaps never confirms the existence of a
workspace you cannot see through a status-code difference.
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 bearer |
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/. A request for a site
that does not exist or that you cannot access returns 404. from and 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. Resolves cross-device merges so a row appears for every anonymous id ever bound to the user.
{ "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 known user matches (site_id, user_id).
The raw event payload and the parsed user-agent string are deliberately omitted from this response, because they may carry PII you 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. Authenticate with the site bearer for that site. A request
for a site you cannot access returns 401, not 403, so a probing caller
cannot confirm a site 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-submits the row’s payload to the ingest endpoint.
200 { id, replayed_at }on accept. The DLQ row is deleted.502 ingest_rejectedon any other status. The DLQ row stays in place.502 ingest_unreachableon network error.
The replay re-enters the full ingest pipeline by design, so consent, rate-limit, and 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).