Skip to content

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.

MethodPathPurpose
POST/eventsBrowser-side ingest.
POST/s2s/eventsServer-to-server ingest.
GET/healthzLiveness 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.

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.

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

HeaderRequiredNotes
Authorization: Bearer <api-key>yesYour workspace API key.
X-Site-Id: <site_id>yesThe site to attribute events to.
X-Consent: <token>when consent is requiredOpaque consent token. Stored 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.
401api_key_requiredThe bearer is missing, malformed, or not a known API key. Body shape differs from the standard envelope. See Authentication.
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 differences:

  • Authentication uses a server-scoped API key. Send a key carrying the ingest:write scope 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.

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.

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.

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.

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.