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.

TypeScript definitions for every event type ship in the @clickstreamhq/shared-types package; importing from there gives you the exact surface described below.

Event types

TypeEmitterWhen
pageviewSDKEvery page load + SPA route change (if autoTrackPageviews: true).
clickSDKEvery trusted click on an element the SDK decides to capture (see auto-capture rules).
scrollSDKThrottled scroll-depth samples at 10 %, 25 %, 50 %, 75 %, 100 %.
formSDKForm submit, focus, blur, and field change (universal form capture).
customSDK / serverAnything you call trackEvent() on. Most common carrier for application-defined events.
identifySDK / servertracker.identify(email) / server-side POST /v1/events with 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
  visitorId: string;                  // first-party visitor id (web: _cs_vid cookie; native: app-persisted)
  sessionId: string;                  // SDK session id (resets after 30 min idle)
  timestamp: number;                  // ms since epoch (server overrides if >5 min skew)
  page: {
    url?: string;                     // full URL — REQUIRED for web, optional for native/server
    path: string;                     // web: pathname. native/server: a screen name or route.
    title: string;                    // document.title at event time / screen title
    referrer?: string;                // document.referrer
  };
  device: {
    userAgent: string;                // navigator.userAgent (truncated to 512 chars)
    viewport: { width: number; height: number };
    screen?: { width: number; height: number };
    clientPlatform?: 'web' | 'ios' | 'android' | 'react-native' | 'server';
                                      // surface that emitted the event. Absent = 'web'.
                                      // Native/server values relax the page schema (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.

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. 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 (truncated to 256 chars)
    text: string;                     // inner text (PII-scrubbed, truncated)
    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: the SDK captures clicks on <button>, <a>, <input type="submit">, elements with role="button", and elements with data-track="1". Everything else is ignored unless you call trackClick(element) manually.

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
  fieldCount: number;                 // number of inputs in the form
  formSelector?: string;              // unique selector for the form
  raw_fields?: string;                // JSON map of canonical field names + types
  detected_name?: boolean;
  detected_email?: boolean;
  detected_phone?: boolean;
  completion_rate?: number;           // 0–100
  values?: Record<string, string>;    // encrypted server-side before D1 storage
  files?: Array<{
    name: string;
    field: string;
    size: number;
    type: string;
    text_excerpt?: string;
  }>;
  form_submission_id?: string;        // UUID for async file excerpt joins
  submitted_at?: number;              // browser wall-clock ms
}

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 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
blob8country | city
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 (only populated when is_bot=1; empty string for human traffic)
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
double20reserved

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).

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

{
  "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, "received": 1, "timestamp": 1713797640592 }

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: t.example.com
X-API-Key: cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "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://t.example.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] = [
    "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("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://t.example.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