Ingest endpoints
The hosted ingest endpoint lives at collect.leadmaps.nl. Bodies are JSON;
ingest endpoints additionally accept gzip and brotli content-encodings. This
page documents every header, status code, and error envelope you will deal
with as a caller.
Endpoint table
Section titled “Endpoint table”| Method | Path | Purpose |
|---|---|---|
POST | /events | Browser-side ingest. |
POST | /s2s/events | Server-to-server ingest. |
GET | /healthz | Liveness check. |
The unprefixed paths and the matching /v1/ paths are aliases of each other
and both stay available. See the versioning policy for
how the API surface evolves.
Authentication
Section titled “Authentication”Ingest requests authenticate with an API key as the bearer token. Provision
a key for your workspace at
app.leadmaps.nl/settings/api-keys,
then send it as the bearer plus an X-Site-Id: <site_id> header naming the
site you want to attribute events to. The key must carry the ingest:write
scope (or admin).
Authorization: Bearer <your-api-key>X-Site-Id: <site_id>A request whose bearer is missing, malformed, or not a known key is rejected
with a 401 body:
{ "error": "api_key_required", "upgrade_docs": "https://app.leadmaps.nl/settings/api-keys"}This shape differs from the standard {"error": {"code": ...}} envelope on
purpose: the dashboard matches on these exact keys to deep-link you to the
api-keys settings page.
API keys are created and revoked from
app.leadmaps.nl/settings/api-keys.
The plaintext key is shown exactly once at creation time; store it then,
because leadmaps keeps only a hash and a masked preview afterward.
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 <api-key> | yes | Your workspace API key. |
X-Site-Id: <site_id> | yes | The site to attribute events to. |
X-Consent: <token> | when consent is required | Opaque consent token. Stored 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 | api_key_required | The bearer is missing, malformed, or not a known API key. Body shape differs from the standard envelope. See Authentication. |
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 differences:
- Authentication uses a server-scoped API key. Send a key carrying the
ingest:writescope as the bearer. - No client-only enrichment. Geo lookup, browser parsing, bot detection, and internal-traffic detection are skipped, because server peers and library user-agents are noise rather than signal. Events ingested here are never tagged as bot or internal and carry no geo or browser fields.
Headers, body shape, and response codes are otherwise identical to
/events.
GET /healthz
Section titled “GET /healthz”Returns 200 with {"status":"ok"}. The endpoint stays green even while
downstream systems are degraded, so it is safe to use as a simple liveness
probe.
Abuse rate limits
Section titled “Abuse rate limits”Several independent rate-limit gates run in front of every ingest request: a per-client gate, a per-key gate, and a per-workspace gate. Together they keep a flood of unsigned requests, a runaway script holding a valid key, and a single noisy tenant from affecting everyone else.
All gates respond with 429 Too Many Requests, Retry-After: 1, and a
categorical x-ratelimit-reason header. The body is one of:
// infrastructure protection (per-client, per-key){ "error": "rate_limited" }
// per-workspace billing limit{ "error": { "code": "rate_limit_exceeded", "message": "..." } }The health check is exempt from the gates so a liveness probe never flaps. Per-workspace sustained rates and monthly quotas are documented under Rate limits and quotas.
Wire schema
Section titled “Wire schema”The Event interface from @syntarie/shared:
interface Event { readonly id: string; // UUID v4 issued by the SDK; used for dedup 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 field; 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
server-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, so
do not pattern-match on it.