Skip to main content

Data model

Four primitives form the engine. Everything else is built on top of them.

Event

A single thing a user did, captured as it happens. Events are the raw signal the engine folds into state.

FieldWhat it carries
distinct_idWho did it: the identifier your code knows the user by
event_nameWhat happened, as a lowercase dotted name (order.completed); a leading $ marks a name the platform owns
propertiesAnything else about the moment, as a JSON object; three reserved keys mutate user state (below)
timestampWhen it happened, stamped by the sender
session_idThe replay session it belongs to, if any
contextWhere it came from: locale, OS, SDK and SDK version

Events are immutable: nothing edits one after the fact, so the history stays trustworthy and everything current-state lives in user state instead. The exact wire rules, bounds and error codes are on the Ingest API page; the console browses the stream on Events.

User State

A live, queryable projection of everything known about a user (derived from their events, updated the moment a new one arrives). This is the source of truth analytics, flags, and messaging all share.

The profile is cumulative, one per person, and any event may mutate it through three reserved property keys:

KeyEffect on the profile
$setOverwrites each named property with the new value
$set_onceSets each named property only where none exists yet
$unsetRemoves the listed properties
JSON
{
  "event_name": "plan.upgraded",
  "distinct_id": "u_8f3a",
  "properties": {
    "$set": { "$email": "ada@example.com", "plan": "scale" },
    "$set_once": { "first_seen_source": "organic" }
  }
}

Keys with a $ prefix are the platform’s: $email, $name, $avatar and $phone are the reserved traits the console renders as a person’s fields, and everything unprefixed is yours, rendered as a table. The distinction is deliberate: an unprefixed email stays a custom property and is never promoted to the person’s address, because your email may mean something else entirely. A malformed mutation is dropped and reported; the event itself is kept, because it genuinely happened.

Identity

Every event names a distinct_id, and a person may accumulate several: the anonymous id a browser minted before sign-up, the user id your backend knows. $identify (sent by SDKs, or POST /v1/identify) declares two ids the same person; the engine merges them to one canonical person, folds their profiles together, and every id keeps working. $group associates a person with a group and its traits, so state can be carried by an account as well as an individual. The merged people are what People shows and cohorts select over.

Decision

A rule evaluated against user state: who is in a cohort, who gets a flag, who qualifies for a message. Because decisions read live state, they are never stale.

Concretely: a flag rule, a cohort definition and a message audience are all predicates over the same live profile, which is why a person who upgrades is in the new cohort, the new rollout and the new audience the moment the $set lands, with no sync between three products.

Action

What the engine does when a decision fires: expose a feature, send a message, trigger a workflow.

Actions close the loop: a message lands, a workflow runs, a feature turns on, and each of those produces events of its own for the next decision to read.

How the four pillars connectEventUser stateDecisionAction