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.
| Field | What it carries |
|---|---|
| distinct_id | Who did it: the identifier your code knows the user by |
| event_name | What happened, as a lowercase dotted name (order.completed); a leading $ marks a name the platform owns |
| properties | Anything else about the moment, as a JSON object; three reserved keys mutate user state (below) |
| timestamp | When it happened, stamped by the sender |
| session_id | The replay session it belongs to, if any |
| context | Where 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:
| Key | Effect on the profile |
|---|---|
| $set | Overwrites each named property with the new value |
| $set_once | Sets each named property only where none exists yet |
| $unset | Removes the listed properties |
{
"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.