Skip to content

Collector endpoints

The collector exposes four endpoints. Default bind is 0.0.0.0:8080. Bodies are JSON; ingest endpoints additionally accept gzip and brotli content-encodings. The full canonical contract — every header, every status code, every error envelope — lives in the in-tree docs/API_CONTRACT.md. This page is a developer-friendly summary; the contract is the authority.

MethodPathPurpose
POST/events (alias /v1/events)Browser-side ingest.
POST/s2s/events (alias /v1/s2s/events)Server-to-server ingest.
GET/healthz (alias /v1/healthz)Liveness check.
GET/metrics (alias /v1/metrics)Prometheus exposition.

The unprefixed paths are aliases of the /v1/ paths and are guaranteed through the entire v1.x line — see Versioning §2.1.

The collector exposes three auth postures via API_KEY_MODE (disabled, tag, require). Production deployments since v2.1 ship with API_KEY_MODE=require — see Breaking change in v2.1 below.

  • disabled (legacy default for v0.x–v2.0): the bearer is the public site_id; no api_keys lookup happens.
  • tag (transitional): an api_key is tried first; on no-match the request falls back to the legacy bearer-as-site_id flow with a reason=legacy_auth log line so operators can monitor the migration.
  • require: the bearer MUST be an active api_keys row. Anything else — missing header, random junk, a UUID-shaped legacy site_id — is rejected with 401 + the actionable api_key_required body.

Breaking change v2.1: API_KEY_MODE=require

Section titled “Breaking change v2.1: API_KEY_MODE=require”

The production collector at collect.leatmap.com runs with API_KEY_MODE=require since the sprint-23 v2.1 hardening (TRK-124). Any ingest request whose Authorization: Bearer <token> does not match an active row in api_keys is rejected with a flat 401 body:

{
"error": "api_key_required",
"upgrade_docs": "https://app.leatmap.com/settings/api-keys"
}

This shape diverges from the standard {"error": {"code": ...}} envelope on purpose: the dashboard’s caught-fetch handler matches on the exact keys to deep-link the user to the api-keys settings page.

Migration: provision an api_key under your workspace at app.leatmap.com/settings/api-keys. Send the api_key as the bearer token plus an X-Site-Id: <site_id> header naming the site you want to attribute events to. The api_key must carry the ingest:write scope (or admin).

Internal consumers running their own collector instance can keep the legacy contract by setting API_KEY_MODE=disabled (or stay on tag during a phased migration).

The collector exposes three admin endpoints under /admin/api-keys that the dashboard uses to provision workspace api keys. They live outside the per-workspace api_key scope ladder by design — they exist precisely so the dashboard can mint the very FIRST key for a workspace before any per-workspace keys exist.

MethodPathPurpose
POST/admin/api-keysMint a new key under a workspace; returns the plaintext exactly once.
GET/admin/api-keys?workspace_id=<uuid>List metadata-only rows for a workspace.
DELETE/admin/api-keys/:id?workspace_id=<uuid>Soft-revoke a key (sets revoked_at).

Authentication is a deployment-wide shared secret: every request must carry Authorization: Bearer <ADMIN_TOKEN>. The collector fails closed when ADMIN_TOKEN is unset on the deployment — every admin request returns 503 Service Unavailable so a deploy that forgot the secret cannot silently accept anonymous mints. Set the secret on Fly via fly secrets set ADMIN_TOKEN=<random-token>. The secret is NEVER on the env block — only Fly secrets, only set out-of-band.

The plaintext is lk_live_<32 chars base62> (~190 bits of entropy). Surfaced exactly ONCE on the create response; the row stores only the SHA-256 hash plus the masked prefix/suffix for the dashboard’s listing render.

Browser-side ingest. Single JSON event per request (the EventBatch envelope is reserved for future multi-event payloads).

HeaderRequiredNotes
Authorization: Bearer <token>yes<token> is the site_id (when API_KEY_MODE=disabled) OR an api_key (when API_KEY_MODE=tag|require).
X-Site-Id: <site_id>when bearer is api_keyThe site to attribute events to.
X-Consent: <token>when CONSENT_REQUIRED=trueOpaque consent token. Persisted alongside the event.
Content-Type: application/jsonyes
Content-Encoding: gzip|brnoDecompressed body cap: 5 MB.

The wire shape is the Event interface from @syntarie/shared (see Wire schema below).

StatusCodeWhen
202Accepted. Empty body.
400invalid_jsonBody is not a JSON object.
400missing_fieldRequired field absent.
400schema_validation_failedTracking-plan validation failed in Reject mode.
401unauthorizedBearer missing / malformed / unrecognised (legacy / tag modes).
401api_key_requiredAPI_KEY_MODE=require and the bearer is not a known api_key. Body shape diverges from the standard envelope — see the auth section.
402monthly_quota_exceededWorkspace quota exhausted. SDK retry treats as permanent.
403consent_requiredMissing or invalid X-Consent.
413payload_too_largeDecompressed body exceeded 5 MB.
415unsupported_encodingContent-Encoding not implemented.
429rate_limit_exceededPer-second rate limit.
500internalTransient server failure.

Server-to-server ingest. Mirrors /events with two strict differences:

  • Authentication uses a server key, not a site_id. A site_id presented here is rejected with 401. With API_KEY_MODE=tag|require an api_key with ingest:write scope is also accepted; in require mode every server-key bearer is rejected with the api_key_required body.
  • No client-only enrichment. Geo lookup, UA parsing, bot detection, and internal-traffic detection are skipped — server peers and library UAs are pollution rather than signal. Persisted rows always carry is_bot=false, is_internal=false, country=NULL, city=NULL, region=NULL, browser=NULL, os=NULL.

Headers, body shape, and response codes are otherwise identical to /events.

Returns 200 with {"status":"ok"}. No state is touched — the endpoint stays green even while downstream systems are degraded. Readiness (which would touch the DB) is reserved for a future /readyz.

Returns 200 with the Prometheus text exposition (Content-Type: text/plain; version=0.0.4; charset=utf-8) when the recorder is initialised; 503 with empty body otherwise.

No authentication — operators run Prometheus inside the same private network and firewall the port. See Observability for the metric set and label cardinality rules.

Three independent rate-limit gates run in front of every ingest request. The first two land in v2.1 (TRK-125); the per-workspace billing-shaped gate has shipped since v0.9 (TRK-052).

GateScopeDefault rateDefault burstWhen
Per-IPClient IP (XFF → X-Real-IP → TCP peer)100 req/sec200Before auth — keeps a flood of unsigned requests from burning CPU on signature verification.
Per-api-keyResolved workspace_id1000 req/sec2000After auth — catches a runaway script that holds a valid key.
Per-workspace (TRK-052)Resolved workspace_idConfigurable per workspaceConfigurable per workspaceBilling-shaped sustained rate + monthly quota.

All three gates respond with 429 Too Many Requests, Retry-After: 1, and a categorical x-ratelimit-reason header. The body shape differs between TRK-125 (flat infrastructure protection) and TRK-052 (billing envelope):

// TRK-125 (per-IP, per-api-key)
{ "error": "rate_limited" }
// TRK-052 (per-workspace)
{ "error": { "code": "rate_limit_exceeded", "message": "..." } }

/healthz and /version are exempt from all three gates so operator liveness / version probes never flap.

The TRK-125 gates expose four env knobs, settable via fly secrets set without redeploying:

Env varDefaultNotes
RATE_LIMIT_IP_RPS100Per-IP sustained rate, requests/second.
RATE_LIMIT_IP_BURST200Per-IP burst capacity (two seconds of sustained rate).
RATE_LIMIT_KEY_RPS1000Per-api-key sustained rate. Sized for a chatty SDK install — the per-workspace gate enforces tenant-shaped limits on top.
RATE_LIMIT_KEY_BURST2000Per-api-key burst capacity.

Memory bound: the limiter prunes entries idle for more than 5 minutes, so a flood of unique IPs cannot cause unbounded growth. The sweep runs from a periodic background task and (occasionally) inline on the hot path so a deployment that skips the task still bounds growth.

The Event interface from @syntarie/shared:

interface Event {
readonly id: string; // UUID v4 issued by the SDK; collector dedupes on this
readonly site_id: string;
readonly anon_id: string;
readonly type: string; // e.g. "pageview", "click", "checkout_completed"
readonly url: string;
readonly ts: number; // unix epoch ms
readonly ua?: string; // legacy v0.1 path; new code populates context.ua
readonly referrer?: string;
readonly session_id?: string;
readonly context?: EventContext;
readonly user_id?: string;
readonly previous_user_id?: string; // merge events
readonly traits?: Record<string, unknown>; // identify events
readonly props?: Record<string, unknown>;
}
interface EventContext {
readonly utm?: EventUtm;
readonly referrer?: string;
readonly viewport?: EventDimensions;
readonly screen?: EventDimensions;
readonly language?: string;
readonly timezone?: string;
readonly ua?: string;
}
interface EventUtm {
readonly source?: string;
readonly medium?: string;
readonly campaign?: string;
readonly term?: string;
readonly content?: string;
readonly gclid?: string;
readonly fbclid?: string;
}
interface EventDimensions { readonly w: number; readonly h: number; }

The matching JSON Schema is shipped at the package subpath @syntarie/shared/events.schema.json and is the source of truth for collector-side validation.

Every error response uses the same envelope:

{
"error": {
"code": "<stable_string>",
"message": "<human-readable>",
"details": [/* optional structured per-field errors */]
}
}

The code is part of the contract and what callers branch on. The message is human-readable and may evolve for clarity in any release — do not pattern-match on it.