Dozor

Wire format

The HTTP contract for POST /api/ingest. The SDK ships against this shape; you only need to read it directly when:

  • Debugging at the network layer (a 400 you can't explain)
  • Writing a custom client in a non-JS language
  • Building a tunnel proxy and deciding what to forward
  • Implementing your own batching / retry on top

POST /api/ingest is the only endpoint authenticated by a public project key. Everything else on the dashboard is session-cookie auth and not exposed to SDK clients.

Request

POST /api/ingest HTTP/1.1
Host: your-dashboard.com
X-Dozor-Public-Key: dp_<32 hex chars>
Content-Type: application/json
Content-Encoding: gzip          # only when the body is gzipped
HeaderRequiredNotes
X-Dozor-Public-KeyProject key, format dp_ + 32 hex chars. Copy from Avatar → Manage organizations → [org card] → API keys.
Content-TypeAlways application/json.
Content-EncodingSet to gzip when the body is compressed. The SDK does this for payloads > ~1 KB via CompressionStream.

Body schema

JSON object, Zod-validated server-side:

{
  sessionId: string;            // UUID v4, stable per browser session
  events: Event[];              // rrweb events, max 500 per batch
  metadata?: SessionMetadata;   // first batch + after `identify()`
  // Legacy fields tolerated for back-compat with @kharko/dozor 1.x clients:
  sliceMarkers?: unknown[];
  pageViews?: unknown[];
}

type Event = {
  type: number;                 // rrweb event-type discriminator (0–5)
  data: unknown;                // type-specific payload (rrweb's contract)
  timestamp: number;            // ms since epoch
};

type SessionMetadata = {
  url: string;
  referrer: string;
  userAgent: string;
  screenWidth: number;          // CSS pixels
  screenHeight: number;
  language: string;             // navigator.language, e.g. "en-US"
  userIdentity?: {
    userId: string;             // 1–255 chars
    traits?: Record<string, unknown>;
  };
};

A few things worth knowing:

  • sessionId is generated by the SDK and persisted to sessionStorage so it survives reloads in the same tab. The validator also accepts the reserved 00000000-… (nil) and ffffffff-… (max) UUIDs for synthetic / test sessions.
  • metadata ships on the first batch, and again on the next batch after identify() is called so the updated userIdentity reaches the server. Other batches omit it.
  • events[].data is opaque to the dashboard — it's rrweb's format. The dashboard stores events verbatim; the replay player reads them back through rrweb's Replayer.
  • In-stream markers: instead of a separate sliceMarkers channel, the SDK emits rrweb custom events (type: 5) inline in the event stream. Two tags are reserved — dozor:url (carries { url, pathname } on SPA navigation) and dozor:identity (carries the latest UserIdentity on identify() calls). A dozor:url marker is always followed by a fresh rrweb FullSnapshot (type: 2) so replay can render the new DOM. Hash-only and query-only URL changes are ignored.
  • sliceMarkers / pageViews are legacy protocol fields kept tolerated by the validator so @kharko/dozor 1.2.x builds keep working. Current 1.3.x clients don't send them.

Response codes

CodeBodyWhenWhat the SDK does
204 No ContentemptyBatch acceptedProceed to next batch
400 Bad RequestemptyBody failed Zod validationDrop batch, no retry. Almost always means an SDK ↔ dashboard version mismatch — update the SDK
401 UnauthorizedemptyMissing / unknown keyDrop batch, no retry. Subsequent batches keep returning 401 until the client is redeployed with the fresh key
5xx / networkServer / infra issueRetry with exponential backoff (1 s → 2 s → 4 s), then drop

400 triggers (the most common ones, in case you're chasing one):

  • sessionId is not a valid UUID v4
  • events array exceeds 500 items
  • An event is missing type / data / timestamp
  • The body isn't valid JSON, or gzip decompression failed

401 fix: copy the current key from Avatar → Manage organizations → [org card] → API keys and redeploy the client.

5xx debug path: check your host's function logs (Vercel → project → Logs, or your equivalent observability surface). Persistent 5xx usually points at a database / function-timeout issue on the dashboard side, not the SDK.

CORS

The endpoint sets these headers on every response:

HeaderValue
Access-Control-Allow-Origin*
Access-Control-Allow-HeadersContent-Type, X-Dozor-Public-Key, Content-Encoding
Access-Control-Allow-MethodsPOST, OPTIONS
Cache-Controlno-store

OPTIONS /api/ingest always returns 204 with the same CORS headers. No auth required — the browser fires it automatically before the cross-origin POST.

Examples

cURL — minimal valid batch

curl -X POST https://your-dashboard.com/api/ingest \
  -H "X-Dozor-Public-Key: dp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "550e8400-e29b-41d4-a716-446655440000",
    "events": [
      { "type": 4, "data": {}, "timestamp": 1731600000000 }
    ]
  }'

A 204 means the batch landed. The session shows up in Replays within a few seconds.

Custom JS client (no SDK)

When you want wire-level control without depending on @kharko/dozor — typed-language ports, internal QA harnesses, etc.:

src/lib/ingest.ts
const ENDPOINT = "https://your-dashboard.com/api/ingest";

export async function ingest(apiKey: string, payload: unknown) {
  const json = JSON.stringify(payload);
  const headers: Record<string, string> = {
    "X-Dozor-Public-Key": apiKey,
    "Content-Type": "application/json",
  };

  // Compress payloads larger than ~1 KB — same heuristic the SDK uses.
  let body: string | Uint8Array = json;
  if (json.length > 1024) {
    const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
    body = new Uint8Array(await new Response(stream).arrayBuffer());
    headers["Content-Encoding"] = "gzip";
  }

  const res = await fetch(ENDPOINT, { method: "POST", headers, body });
  if (res.status !== 204) throw new Error(`Ingest failed: ${res.status}`);
}

OpenAPI snapshot

A machine-readable copy of this contract lives at openapi.snapshot.json in the repo. It's regenerated by the contract test on every CI run straight from the production Zod validators, so it's always in sync with what the server actually accepts. Useful if you want to generate a typed client (openapi-generator, openapi-typescript, etc.).

See also

On this page