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.
Endpoint table
Section titled “Endpoint table”| Method | Path | Purpose |
|---|---|---|
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.
Authentication
Section titled “Authentication”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 publicsite_id; noapi_keyslookup happens.tag(transitional): an api_key is tried first; on no-match the request falls back to the legacy bearer-as-site_idflow with areason=legacy_authlog line so operators can monitor the migration.require: the bearer MUST be an activeapi_keysrow. Anything else — missing header, random junk, a UUID-shaped legacysite_id— is rejected with401+ the actionableapi_key_requiredbody.
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).
Admin endpoints (/admin/api-keys)
Section titled “Admin endpoints (/admin/api-keys)”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.
| Method | Path | Purpose |
|---|---|---|
POST | /admin/api-keys | Mint 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.
POST /events
Section titled “POST /events”Browser-side ingest. Single JSON event per request (the EventBatch
envelope is reserved for future multi-event payloads).
Headers
Section titled “Headers”| Header | Required | Notes |
|---|---|---|
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_key | The site to attribute events to. |
X-Consent: <token> | when CONSENT_REQUIRED=true | Opaque consent token. Persisted alongside the event. |
Content-Type: application/json | yes | |
Content-Encoding: gzip|br | no | Decompressed body cap: 5 MB. |
The wire shape is the Event interface from @syntarie/shared (see
Wire schema below).
Status codes
Section titled “Status codes”| Status | Code | When |
|---|---|---|
202 | — | Accepted. Empty body. |
400 | invalid_json | Body is not a JSON object. |
400 | missing_field | Required field absent. |
400 | schema_validation_failed | Tracking-plan validation failed in Reject mode. |
401 | unauthorized | Bearer missing / malformed / unrecognised (legacy / tag modes). |
401 | api_key_required | API_KEY_MODE=require and the bearer is not a known api_key. Body shape diverges from the standard envelope — see the auth section. |
402 | monthly_quota_exceeded | Workspace quota exhausted. SDK retry treats as permanent. |
403 | consent_required | Missing or invalid X-Consent. |
413 | payload_too_large | Decompressed body exceeded 5 MB. |
415 | unsupported_encoding | Content-Encoding not implemented. |
429 | rate_limit_exceeded | Per-second rate limit. |
500 | internal | Transient server failure. |
POST /s2s/events
Section titled “POST /s2s/events”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. WithAPI_KEY_MODE=tag|requirean api_key withingest:writescope is also accepted; inrequiremode every server-key bearer is rejected with theapi_key_requiredbody. - 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.
GET /healthz
Section titled “GET /healthz”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.
GET /metrics
Section titled “GET /metrics”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.
Abuse rate limits
Section titled “Abuse rate limits”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).
| Gate | Scope | Default rate | Default burst | When |
|---|---|---|---|---|
| Per-IP | Client IP (XFF → X-Real-IP → TCP peer) | 100 req/sec | 200 | Before auth — keeps a flood of unsigned requests from burning CPU on signature verification. |
| Per-api-key | Resolved workspace_id | 1000 req/sec | 2000 | After auth — catches a runaway script that holds a valid key. |
| Per-workspace (TRK-052) | Resolved workspace_id | Configurable per workspace | Configurable per workspace | Billing-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.
Tuning
Section titled “Tuning”The TRK-125 gates expose four env knobs, settable via fly secrets set
without redeploying:
| Env var | Default | Notes |
|---|---|---|
RATE_LIMIT_IP_RPS | 100 | Per-IP sustained rate, requests/second. |
RATE_LIMIT_IP_BURST | 200 | Per-IP burst capacity (two seconds of sustained rate). |
RATE_LIMIT_KEY_RPS | 1000 | Per-api-key sustained rate. Sized for a chatty SDK install — the per-workspace gate enforces tenant-shaped limits on top. |
RATE_LIMIT_KEY_BURST | 2000 | Per-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.
Wire schema
Section titled “Wire schema”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.
Error envelope
Section titled “Error envelope”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.