Event schema

Every event accepted by POST /v1/events conforms to one of seven types below. All events share a common envelope (visitorId, sessionId, timestamp, page, device), and type-specific extensions add the fields that are meaningful for that event.

The schemas below are the contract the collector validates against. If you want them as TypeScript types, npm install @clickstreamhq/shared-types (published, public, currently 1.0.1) and import from the /events subpath — the event interfaces in that release are the same source file this page documents. Other subpaths of that package lag the collector; treat this page, not the package, as the reference for anything outside /events.

Event types

TypeEmitterWhen
pageviewSDKEvery page load + SPA route change (when autoTrack.pageviews is on, the default).
clickSDKEvery click on the page while autoTrack.clicks is on — there is no element allow-list. See auto-capture rules below.
scrollSDKOne event per depth milestone reached: 25 %, 50 %, 75 %, 90 %, 100 %. Each milestone fires at most once per page.
formSDKForm submit, focus, blur, and field change (universal form capture).
customSDK / serverAnything you send through window.clickstream.trackEvent(...). Most common carrier for application-defined events.
identifySDK / serverwindow.clickstream.identify(email, phone?) / a server-side POST /v1/events with an identify payload.
_consent_transitionSDKInternal — fires when the visitor changes consent state. Never carries page content.

Campaign redirect links use a separate /r attribution route. Do not send email_open or email_click payloads to /v1/events; they are not part of the public ingestion contract.

Common envelope

Every event has these fields:

interface BaseEvent {
  type: EventType;                    // one of the types above
  _eid?: string;                      // stable event id, max 128 chars — REQUIRED in production
                                      // unless the request carries an Idempotency-Key header
  visitorId: string;                  // first-party visitor id (web: _cs_vid cookie; native: app-persisted), max 128
  sessionId: string;                  // SDK session id (resets after 30 min idle), max 128
  timestamp: number;                  // ms since epoch (server overrides if >5 min skew)
  page: {
    url?: string;                     // full URL — REQUIRED for web, optional for native/server.
                                      // Longer than 2048 chars is truncated, not rejected.
    path: string;                     // web: pathname. native/server: a screen name or route. Truncated at 2048.
    title: string;                    // document.title at event time / screen title. Truncated at 512.
    referrer?: string;                // document.referrer. Truncated at 2048.
  };
  device: {
    userAgent: string;                // navigator.userAgent — hard max 512 chars.
                                      // Longer is REJECTED, not truncated.
    viewport: { width: number; height: number };
    screen?: { width: number; height: number };
    clientPlatform?: 'web' | 'ios' | 'android' | 'react-native' | 'server';
                                      // surface that emitted the event. Absent = 'web'.
                                      // SERVER-AUTHORITATIVE — the collector rewrites it
                                      // based on your key type (see below).
    fingerprint?: string;             // composite device fingerprint
    fingerprintConfidence?: number;   // 0–100 — confidence the fingerprint is stable
    gpuVendor?: string;
    gpuRenderer?: string;
    connectionType?: string;          // '4g', '3g', 'wifi', …
    pixelRatio?: number;
    timezone?: string;                // IANA, e.g. 'America/New_York'
    language?: string;                // 'en-US'
    platform?: string;                // navigator.platform: 'MacIntel', 'iPhone', 'Linux'
                                      // (stealth-detection input — NOT clientPlatform above)
    automation?: {
      score?: number;                 // 0–100 browser-control confidence
      signals?: string[];             // bounded non-PII automation labels
      webdriver?: boolean;            // navigator.webdriver when exposed
    };
  };
  consent?: {
    analytics: boolean;
    identityResolution?: boolean;
    thirdPartySharing?: boolean;
    timestamp?: number;
  };
}

Platform and the page schema

device.clientPlatform declares which surface emitted the event. It is distinct from device.platform (the navigator.platform string used by stealth detection). The collector reads clientPlatform to decide how strictly to validate page:

device.clientPlatformpage.urlpage.pathNotes
'web' or absentRequired, must parse as http(s)Required string (a pathname)Byte-identical to the original web contract.
'ios', 'android', 'react-native'Optional; if present may use a custom scheme (myapp://checkout)Free string — a screen name or route (CheckoutScreen, Settings/Notifications)Native screens have no browsable URL.
'server'OptionalFree string — a logical routeServer-to-server events with no browser context.

clientPlatform is server-authoritative, and the collector rewrites it before validation. This is the single most confusing thing on this page if you hit it blind:

Native and server events authenticate with a dedicated mobile (cs_mob_live_*) or server (cs_srv_live_*) key. Those keys are exempt from the browser Origin/Referer gate, so a native app or backend posts directly with no Origin header. Server event writes additionally require a timestamped HMAC signature using the server key. See API keys + auth and Mobile apps.

Native and server clients are also exempt from the browser UA-scraper bot heuristics — a library UA like OkHttp or CFNetwork under a mobile/server key is expected, not flagged. The exemption is gated on key posture and declared platform, not on the user agent: a library UA on a website key still forces a bot classification.

Type-specific fields

pageview

interface PageViewEvent extends BaseEvent {
  type: 'pageview';
  performance?: {
    loadTime?: number;
    domContentLoaded?: number;
    firstContentfulPaint?: number;
  };
}

The server also derives scroll depth and time-on-page from later scroll / _attention_summary events in the session.

click

interface ClickEvent extends BaseEvent {
  type: 'click';
  element: {
    selector: string;                 // unique CSS selector, max 512 chars (longer is rejected)
    text: string;                     // inner text (PII-scrubbed), max 512 chars
    x: number;                        // viewport-relative X coordinate
    y: number;                        // viewport-relative Y coordinate
    pageX?: number;                   // document-relative X coordinate
    pageY?: number;                   // document-relative Y coordinate
    normX?: number;                   // 0–1 normalized document X
    normY?: number;                   // 0–1 normalized document Y
    viewportWidth?: number;
    viewportHeight?: number;
    docWidth?: number;
    docHeight?: number;
  };
}

Auto-capture rules: with autoTrack.clicks on, the SDK listens on document in the capture phase and emits a click event for every click, not just interactive elements. There is no element allow-list and no per-element opt-in attribute. If you want fewer click events, turn autoTrack.clicks off and emit your own custom events with window.clickstream.trackEvent(...).

The SDK truncates element.text to the first 100 characters before sending, even though the collector accepts up to 512.

scroll

interface ScrollEvent extends BaseEvent {
  type: 'scroll';
  depth: number;                      // % of page scrolled (0–100)
  timeOnPage: number;                 // ms on page at capture time
}

form

interface FormEvent extends BaseEvent {
  type: 'form';
  action: 'focus' | 'blur' | 'submit' | 'abandon';
  formId: string;                     // <form id=…> or selector-derived, max 256
  fieldCount: number;                 // number of inputs in the form, 0–1000
  form_selector?: string;             // unique selector for the form, max 512
  raw_fields?: string;                // JSON map of canonical field names + types, max 4096
  detected_name?: boolean;
  detected_email?: boolean;
  detected_phone?: boolean;
  completion_rate?: number;           // 0–100
  values?: Record<string, string>;    // encrypted server-side before D1 storage.
                                      // Max 10,000 chars per value.
  files?: Array<{                     // metadata only — file bytes are never accepted. Max 25 entries.
    name: string;                     // max 256
    field: string;                    // max 256
    size: number;                     // bytes, max 100 MB (metadata value only)
    type: string;                     // max 128
  }>;
  form_submission_id?: string;        // UUID for idempotent submission storage
  submitted_at?: number;              // browser wall-clock ms
}

Field-value capture is subject to a hard PII floor the collector enforces regardless of configuration — payment card, CVV, government ID, bank/financial and health fields are never captured, and a form the SDK recognises as a checkout is dropped whole. A request carrying payment-instrument material anywhere is rejected with 400 prohibited_payment_data before anything is stored.

custom

interface CustomEvent extends BaseEvent {
  type: 'custom';
  name: string;                       // e.g. 'docs_filter_changed'
  category?: string;                  // e.g. 'interaction'
  action?: string;                    // e.g. 'click'
  label?: string;                     // up to 4 KB; e.g. 'primary_button'
  value?: number;                     // numeric value for customer-defined scoring
}

identify

interface IdentifyEvent extends BaseEvent {
  type: 'identify';
  hem: string;                        // SHA-256 hashed email (lowercase trim)
  hemMd5?: string;                    // MD5 hashed email (partner-compatible enrichment lookup format)
  rawEmail?: string;                  // encrypted server-side before storage
  rawPhone?: string;                  // encrypted server-side before storage
  identity?: IdentityInfo;            // hashed phone, CRM ids, click ids, MAID, social ids, UTMs
}

Hashing starts client-side. The SDK sends hashed email and phone values for first-party identity joins. When raw identity capture is enabled for a site, raw values travel only to the site's first-party ClickStream endpoint, are encrypted server-side, and are not shown without the audited reveal flow. Server-to-server or native app senders should lowercase/trim email before hashing and send SHA-256 as hem; hemMd5 is optional compatibility format for approved enrichment workflows.

Analytics Engine field mapping

The collector writes every event into the clickstream_events_v2 dataset in Cloudflare Analytics Engine. Field mappings (20 blobs + 20 doubles + 1 index):

Index: clientId (multi-tenant scope).

Blobs (string fields — pipe-delimited encoding to maximize the 20-field limit):

BlobFields
blob1event_type | event_name
blob2page_url
blob3page_path
blob4referrer
blob5session_id
blob6visitor_id
blob7device_type | browser | os | gpu, then low-sensitivity device-fidelity tokens and locale tokens (language | languages | translatedTo). Older rows carry only the first four.
blob8country | city | region | postal_code. Older rows carry only the first two.
blob9element_selector | element_text
blob10form_id | custom_category | custom_action | custom_label
blob11hmacHem | hemMd5 — primary identity key
blob12hmacPhone — secondary identity key
blob13customer_id | account_id | user_id | crm_contact_id | order_id
blob14clickstream_id | referring_clickstream_id
blob15maid | maid_type
blob16google | fb | linkedin | apple (social IDs)
blob17bot_category | bot_name | effective classification | confidence | rule version | assessed-at | verdict source | evidence | original automated classification/confidence/version/assessed-at/evidence. Older rows may contain only the first two tokens.
blob18click IDs: gclid | fbclid | msclkid | ttclid | dclid | gbraid | wbraid | li_fat_id | campaignCode
blob19UTM: source | medium | campaign | term | content
blob20ip_hash (SHA-256 of IP; never the raw value)

Doubles (numeric fields):

DoubleField
double1timestamp (ms)
double2scroll_depth_percent
double3time_on_page_ms
double4, double5viewport_width, viewport_height
double6, double7click_x, click_y
double8form_field_count
double9custom_value
double10, double11, double12page_load_time_ms, dom_content_loaded_ms, fcp_ms
double13fingerprint_confidence (0–100)
double14, double15is_vpn, connection_type
double16, double17latitude, longitude
double18, double19bot_score, is_bot
double20device/session posture bitmask

The blob layout is stable across releases — events written under a given mapping always deserialize against the same schema, regardless of when they were ingested.

Ingestion endpoint

POST https://t.example.com/v1/events (use your registered first-party tracking domain).

Native mobile and server properties use the shared collector at https://feynman.clickstream.com/v1/events; their dedicated cs_mob_live_* / cs_srv_live_* keys are provenance-exempt and do not require browser DNS.

Request limits

Every one of these is enforced by the collector. Exceeding one is a hard rejection, not a truncation, unless the row says otherwise.

LimitValueOn breach
Uncompressed request body10 MB413 payload_too_large
Gzipped request body (compressed bytes)1 MB413 payload_too_large
Gzipped request body (after decompression)10 MB413 payload_too_large, aborted mid-stream
Events per batch1–25400 validation_error
One mapped event after server-side enrichment120,000 bytes413 batch_too_large — one oversized event fails the request; split it or trim the payload
Idempotency-Key header512 chars400 invalid_idempotency_key
_eid128 chars400 validation_error
visitorId, sessionId128 chars each400 validation_error
page.url, page.path, page.referrer2048 charsTruncated, event still accepted
page.title512 charsTruncated, event still accepted
device.userAgent512 chars400 validation_error
custom.name, custom.category256 chars400 validation_error
custom.label4096 chars400 validation_error
element.selector, element.text512 chars each400 validation_error

Gzip has one extra requirement: Content-Encoding: gzip must be paired with a Content-Type containing application/json. Otherwise the request is rejected 400 before decompression, and this one response does not use the usual code taxonomy — the body is literally {"error":"Content-Type must be application/json"}. Cloudflare Workers do not auto-decompress request bodies, so the collector does it itself and applies the caps above.

Response status codes

StatusMeaning
202Every valid, consent-eligible event is durably committed. Read accepted, duplicates, dropped, rejected, and errors — a 202 does not mean everything you sent was stored.
400validation_error, invalid_json, idempotency_required, invalid_idempotency_key, prohibited_payment_data, or prohibited_or_unsafe_event. Do not retry unchanged.
401Auth or server-signature failure — see the error taxonomy on API keys + auth.
403origin_required, domain_not_allowed, site_scope_ambiguous, insufficient_permission, account_suspended.
409idempotency_conflict — an event id was reused with different content.
413payload_too_large or batch_too_large.
429rate_limit_exceeded (request-rate bucket), usage_limit_exceeded (monthly pageview budget), or accepted_event_safety_ceiling_reached (non-pageview event ceiling). The latter two are billing stops with Retry-After: 3600, not outages — retrying will not clear them.
503service_unavailable, storage_unavailable, usage_metering_unavailable, projection_pending, usage_projection_pending. All retryable with the same event ids; Retry-After: 5.

Example request:

POST /v1/events HTTP/1.1
Host: t.example.com
X-API-Key: cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Origin: https://example.com
Content-Type: application/json

{
  "_eid": "019fb852-6887-7af1-a0e7-84a9383c693a",
  "type": "pageview",
  "visitorId": "vis_abc123",
  "sessionId": "sess_xyz456",
  "timestamp": 1713797640000,
  "page": { "url": "https://example.com/docs/getting-started", "path": "/docs/getting-started", "title": "Getting started" },
  "device": { "userAgent": "Mozilla/5.0 …", "viewport": { "width": 1280, "height": 800 } }
}

Response (202):

{
  "success": true,
  "accepted": 1,
  "duplicates": 0,
  "received": 1,
  "billing": {
    "monthlyPageviews": {
      "mode": "full",
      "billingPeriod": "2026-04",
      "used": 18412,
      "limit": 500000,
      "safetyCeiling": 500000,
      "attempted": 1
    }
  },
  "delivery": "durable_outbox",
  "receiptIds": ["aed1_9f3c…"],
  "ledgerKeys": ["…"],
  "timestamp": 1713797640592
}

Field notes, because several are conditional and silence is meaningful:

The same numbers also ride as response headers, which is usually the cheaper way to read them — see the usage headers on Rate limits.

Native app screen view example — mobile key, no Origin, a screen name as page.path, no http URL:

POST /v1/events HTTP/1.1
Host: feynman.clickstream.com
X-API-Key: cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "_eid": "9d989ec7-126f-4ac0-b4ec-b967bc6600da",
  "type": "pageview",
  "visitorId": "cs_visitor_abc",
  "sessionId": "cs_session_xyz",
  "timestamp": 1713797640000,
  "page": { "path": "CheckoutScreen", "title": "Checkout" },
  "device": {
    "userAgent": "AcmeApp/2.1.0 CFNetwork iOS/17.5",
    "viewport": { "width": 390, "height": 844 },
    "clientPlatform": "ios"
  }
}

Swift (iOS) — URLSession

let url = URL(string: "https://feynman.clickstream.com/v1/events")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", forHTTPHeaderField: "X-API-Key")

let event: [String: Any] = [
    "_eid": UUID().uuidString,            // persist with the event until 202
    "type": "pageview",
    "visitorId": visitorId,            // persisted in Keychain, once per install
    "sessionId": sessionId,            // rotated after 30 min idle
    "timestamp": Int(Date().timeIntervalSince1970 * 1000),
    "page": ["path": "CheckoutScreen", "title": "Checkout"],
    "device": [
        "userAgent": "AcmeApp/2.1.0 CFNetwork iOS/17.5",
        "viewport": ["width": 390, "height": 844],
        "clientPlatform": "ios"
    ]
]
req.httpBody = try JSONSerialization.data(withJSONObject: ["events": [event]])
URLSession.shared.dataTask(with: req).resume()

Kotlin (Android) — OkHttp

val body = JSONObject().put("events", JSONArray().put(
    JSONObject()
        .put("_eid", java.util.UUID.randomUUID().toString()) // persist until 202
        .put("type", "pageview")
        .put("visitorId", visitorId)   // persisted once per install
        .put("sessionId", sessionId)   // rotated after 30 min idle
        .put("timestamp", System.currentTimeMillis())
        .put("page", JSONObject().put("path", "CheckoutScreen").put("title", "Checkout"))
        .put("device", JSONObject()
            .put("userAgent", "AcmeApp/2.1.0 OkHttp Android/15")
            .put("viewport", JSONObject().put("width", 412).put("height", 915))
            .put("clientPlatform", "android"))
))

val req = Request.Builder()
    .url("https://feynman.clickstream.com/v1/events")
    .addHeader("Content-Type", "application/json")
    .addHeader("X-API-Key", "cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
    .post(body.toString().toRequestBody("application/json".toMediaType()))
    .build()
OkHttpClient().newCall(req).enqueue(/* Callback */)

For the full native pattern including identity hashing and Signals reads, see Mobile apps. For the React Native package, see Install.

See also