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
| Type | Emitter | When |
|---|---|---|
pageview | SDK | Every page load + SPA route change (if autoTrackPageviews: true). |
click | SDK | Every trusted click on an element the SDK decides to capture (see auto-capture rules). |
scroll | SDK | Throttled scroll-depth samples at 10 %, 25 %, 50 %, 75 %, 100 %. |
form | SDK | Form submit, focus, blur, and field change (universal form capture). |
custom | SDK / server | Anything you call trackEvent() on. Most common carrier for application-defined events. |
identify | SDK / server | tracker.identify(email) / server-side POST /v1/events with identify payload. |
_consent_transition | SDK | Internal — 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.clientPlatform | page.url | page.path | Notes |
|---|---|---|---|
'web' or absent | Required, 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' | Optional | Free string — a logical route | Server-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):
| Blob | Fields |
|---|---|
blob1 | event_type | event_name |
blob2 | page_url |
blob3 | page_path |
blob4 | referrer |
blob5 | session_id |
blob6 | visitor_id |
blob7 | device_type | browser | os | gpu |
blob8 | country | city |
blob9 | element_selector | element_text |
blob10 | form_id | custom_category | custom_action | custom_label |
blob11 | hmacHem | hemMd5 — primary identity key |
blob12 | hmacPhone — secondary identity key |
blob13 | customer_id | account_id | user_id | crm_contact_id | order_id |
blob14 | clickstream_id | referring_clickstream_id |
blob15 | maid | maid_type |
blob16 | google | fb | linkedin | apple (social IDs) |
blob17 | bot_category | bot_name (only populated when is_bot=1; empty string for human traffic) |
blob18 | click IDs: gclid | fbclid | msclkid | ttclid | dclid | gbraid | wbraid | li_fat_id | campaignCode |
blob19 | UTM: source | medium | campaign | term | content |
blob20 | ip_hash (SHA-256 of IP; never the raw value) |
Doubles (numeric fields):
| Double | Field |
|---|---|
double1 | timestamp (ms) |
double2 | scroll_depth_percent |
double3 | time_on_page_ms |
double4, double5 | viewport_width, viewport_height |
double6, double7 | click_x, click_y |
double8 | form_field_count |
double9 | custom_value |
double10, double11, double12 | page_load_time_ms, dom_content_loaded_ms, fcp_ms |
double13 | fingerprint_confidence (0–100) |
double14, double15 | is_vpn, connection_type |
double16, double17 | latitude, longitude |
double18, double19 | bot_score, is_bot |
double20 | reserved |
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).
- Auth:
X-API-Keyheader or?key=<apiKey>query param (forsendBeacon). - Batch: one event per request, or up to 25 events in a single
{ events: [...] }batch. - Rate limit: tier-dependent — see Rate limits.
- Validation: Zod schema applied server-side; invalid events return
400 validation_errorwith adetailsarray showing the failing field + message. - Domain gate: browser (website) keys with configured domains require a matching
Origin/Referer.403 domain_not_allowedotherwise. Mobile (cs_mob_live_*) and server (cs_srv_live_*) keys are exempt — no Origin needed. - Native / server: send direct events with a persistent visitor id and current session id, set
device.clientPlatformto'ios'/'android'/'react-native'/'server', and use a screen name or route aspage.path. Do not embed admin keys. See Mobile apps.
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
- Signals API — read the scored snapshot derived from these events
- Signals Feed (WebSocket) — stream every scored event in real time
- API keys + auth — key rotation + reserved scope labels
- Rate limits — per-tier ingestion caps + overage behavior