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
400you 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| Header | Required | Notes |
|---|---|---|
X-Dozor-Public-Key | ✓ | Project key, format dp_ + 32 hex chars. Copy from Avatar → Manage organizations → [org card] → API keys. |
Content-Type | ✓ | Always application/json. |
Content-Encoding | — | Set 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:
sessionIdis generated by the SDK and persisted tosessionStorageso it survives reloads in the same tab. The validator also accepts the reserved00000000-…(nil) andffffffff-…(max) UUIDs for synthetic / test sessions.metadataships on the first batch, and again on the next batch afteridentify()is called so the updateduserIdentityreaches the server. Other batches omit it.events[].datais opaque to the dashboard — it's rrweb's format. The dashboard stores events verbatim; the replay player reads them back through rrweb'sReplayer.- In-stream markers: instead of a separate
sliceMarkerschannel, 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) anddozor:identity(carries the latestUserIdentityonidentify()calls). Adozor:urlmarker 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/pageViewsare legacy protocol fields kept tolerated by the validator so@kharko/dozor1.2.x builds keep working. Current 1.3.x clients don't send them.
Response codes
| Code | Body | When | What the SDK does |
|---|---|---|---|
204 No Content | empty | Batch accepted | Proceed to next batch |
400 Bad Request | empty | Body failed Zod validation | Drop batch, no retry. Almost always means an SDK ↔ dashboard version mismatch — update the SDK |
401 Unauthorized | empty | Missing / unknown key | Drop batch, no retry. Subsequent batches keep returning 401 until the client is redeployed with the fresh key |
5xx / network | — | Server / infra issue | Retry 3× with exponential backoff (1 s → 2 s → 4 s), then drop |
400 triggers (the most common ones, in case you're chasing one):
sessionIdis not a valid UUID v4eventsarray 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:
| Header | Value |
|---|---|
Access-Control-Allow-Origin | * |
Access-Control-Allow-Headers | Content-Type, X-Dozor-Public-Key, Content-Encoding |
Access-Control-Allow-Methods | POST, OPTIONS |
Cache-Control | no-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.:
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
- Resources → Ingest pipeline — what the dashboard does with the batch once it arrives.
- Resources → Security → API key handling — how the key is stored and rotated.