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
| Type | Emitter | When |
|---|---|---|
pageview | SDK | Every page load + SPA route change (when autoTrack.pageviews is on, the default). |
click | SDK | Every click on the page while autoTrack.clicks is on — there is no element allow-list. See auto-capture rules below. |
scroll | SDK | One event per depth milestone reached: 25 %, 50 %, 75 %, 90 %, 100 %. Each milestone fires at most once per page. |
form | SDK | Form submit, focus, blur, and field change (universal form capture). |
custom | SDK / server | Anything you send through window.clickstream.trackEvent(...). Most common carrier for application-defined events. |
identify | SDK / server | window.clickstream.identify(email, phone?) / a server-side POST /v1/events with an 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
_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.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. |
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:
- On a website key (no
keyType), any non-'web'value you send is silently deleted, and the strict web contract in the table above then applies. So a native-shaped payload sent with your website key fails with400 validation_errorfor a missingpage.url— and nothing in the response mentionsclientPlatform. If you are prototyping a mobile or server payload, you need the matching key type; a website key cannot express one. - On a
cs_mob_live_*key the collector stampsreact-nativewhen you omit the field, and on acs_srv_live_*key it stampsserver. An explicit native value on such a key is kept.
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):
| 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, then low-sensitivity device-fidelity tokens and locale tokens (language | languages | translatedTo). Older rows carry only the first four. |
blob8 | country | city | region | postal_code. Older rows carry only the first two. |
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 | 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. |
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 | device/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.
- Auth: website/mobile writes accept an
X-API-Keyheader or?key=<apiKey>query parameter (for browsersendBeacon). A server-key write requiresX-API-Key,X-CS-Timestamp, andX-CS-Signature; query-string authentication does not satisfy the server contract. - Server signature: compute
HMAC-SHA256(serverApiKey, canonicalBytes)and send it asX-CS-Signature: v1=<64 lowercase hex>. The canonical bytes arev1\n<unix-seconds>\nPOST\n/v1/events\n<trimmed Idempotency-Key or empty>\n<exact wire body>.X-CS-Timestampmust be within five minutes of the collector. Sign compressed bytes, not the decompressed JSON, when using gzip. - Batch: one event per request, or up to 25 events in a single
{ events: [...] }batch. - Idempotency: every production event must carry a stable
_eid(maximum 128 characters), or the request must carry anIdempotency-Keyheader. Reuse the same value and the exact same body on every network retry. A replay returns202withaccepted: 0andduplicates: N. A single-event ID/content conflict returns409 idempotency_conflict; in a mixed batch, valid events are still accepted and the conflicting item appears in therejected/errorssubset of the202response. - Consent:
consent.analytics: falseis dropped before hashing, scoring, durable acceptance, billing, live fan-out, identity, or enrichment. If the selected property's Require consent setting is on, an omitted analytics value is also denied; direct mobile/server clients must sendconsent.analytics: trueonly after their CMP or native consent flow has granted analytics. A consent drop returns202withaccepted: 0anddroppedByConsent: 1so clients do not retry a policy denial. - Low-cost partition affinity: official web and mobile SDK IDs use
elp1_<two hex digits>_<unique suffix>. The two hex digits are a deterministic session affinity, so a normal 25-event batch reaches one exact-ledger partition while the unique suffix preserves per-event idempotency. Custom senders may use any stable ID; high-volume senders should use the same format with00–ffderived from a stable session or batch affinity. Never change an existing event's affinity on retry. - Durability:
202means the accepted logical events are transactionally committed to ClickStream's durable event outbox. Its alarm materializes immutable R2 shards; Queue and Analytics Engine are downstream projections and do not define exact export counts. Exact exports freeze the outbox's partition high-water vector and reconcile every included shard before completion. - Rate limit: tier-dependent — see Rate limits.
- Validation: Zod schema applied server-side. A single invalid event returns
400 validation_errorwith adetailsarray (path,message,code). In a batch, valid events are accepted and the failures come back inside the202aserrors— a batch only returns400when every event failed. - 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. Server keys replace browser provenance with the mandatory HMAC contract above. - 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 or the@clickstreamhq/nodeserver SDK.
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.
| Limit | Value | On breach |
|---|---|---|
| Uncompressed request body | 10 MB | 413 payload_too_large |
| Gzipped request body (compressed bytes) | 1 MB | 413 payload_too_large |
| Gzipped request body (after decompression) | 10 MB | 413 payload_too_large, aborted mid-stream |
| Events per batch | 1–25 | 400 validation_error |
| One mapped event after server-side enrichment | 120,000 bytes | 413 batch_too_large — one oversized event fails the request; split it or trim the payload |
Idempotency-Key header | 512 chars | 400 invalid_idempotency_key |
_eid | 128 chars | 400 validation_error |
visitorId, sessionId | 128 chars each | 400 validation_error |
page.url, page.path, page.referrer | 2048 chars | Truncated, event still accepted |
page.title | 512 chars | Truncated, event still accepted |
device.userAgent | 512 chars | 400 validation_error |
custom.name, custom.category | 256 chars | 400 validation_error |
custom.label | 4096 chars | 400 validation_error |
element.selector, element.text | 512 chars each | 400 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
| Status | Meaning |
|---|---|
202 | Every valid, consent-eligible event is durably committed. Read accepted, duplicates, dropped, rejected, and errors — a 202 does not mean everything you sent was stored. |
400 | validation_error, invalid_json, idempotency_required, invalid_idempotency_key, prohibited_payment_data, or prohibited_or_unsafe_event. Do not retry unchanged. |
401 | Auth or server-signature failure — see the error taxonomy on API keys + auth. |
403 | origin_required, domain_not_allowed, site_scope_ambiguous, insufficient_permission, account_suspended. |
409 | idempotency_conflict — an event id was reused with different content. |
413 | payload_too_large or batch_too_large. |
429 | rate_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. |
503 | service_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:
acceptedcounts newly accepted events. A retry of an already-accepted event lands induplicates, notaccepted— that is the idempotency contract working, not a failure.dropped/droppedByConsent/droppedByPostureappear only when non-zero.rejectedanderrorsappear only when part of a batch failed validation.billingappears only when the request contained at least one non-botpageview. A batch of clicks, or a pageview classified as a bot, returns nobillingblock at all — that is not an error.monthlyPageviews.modeisfull,metered_overage, orblocked.signalsCoverageappears only when the request contained at least one bot-classed event, and carries just{ "mode": "full" | "degraded" }.deliveryis one ofdurable_outbox,durable_outbox_recovery,durable_ledger,durable_ledger_recovery,durable_queue,direct.receiptIdsareaed1_<64 hex>reconciliation receipts for the accepted subset. An event that hit a content conflict never gets one.
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
- Signals API — read the scored snapshot derived from these events
- Signals Feed (WebSocket) — stream every scored event in real time
- API keys + auth — key types, permission scopes, rotation, error codes
- Rate limits — per-tier ingestion caps + overage behavior