Data model

Cohorly's data model has three core concepts - events, users, and projects - all scoped by project. Event properties are stored as schemaless JSON, so you never define a schema up front.

Projects

A project represents one app you are tracking. All data is scoped to a project; create projects in the dashboard under Settings.

ts
interface Project {
id: number;
name: string;
token: string; // UUID, authenticates ingestion
created_at: number; // unix ms
}

The token is what SDKs send with each request; Cohorly resolves it to a project_id and stores data under it. Ingestion is authed by this token, while the admin and query API is authed by your account API key.

Events

An event is an immutable record of something a user did. Events belong to a project and a distinct_id, carry a time, and store their properties as jsonb.

ColumnTypeNotes
project_idintegerOwning project.
eventtextEvent name.
distinct_idtextUser/device this event belongs to.
timebigintUnix milliseconds.
$insert_idtextIdempotency key for deduplication.
propertiesjsonbArbitrary event properties (GIN indexed).

Properties are stored as jsonb with a GIN index, so filters and breakdowns on property keys are queryable without a fixed schema.

Users

A user (profile) aggregates identity and profile properties for a distinct_id within a project. Profiles are mutated via the /engage endpoint (the People API), while the derived stats come from the underlying events.

ts
interface UserProfile {
distinct_id: string;
properties: Record<string, unknown>; // from $set / $set_once / $add / $unset
first_seen: number | null; // unix ms
last_seen: number | null; // unix ms
event_count: number;
}

Identity

Every visitor starts anonymous: the SDK mints a random id and stamps it as both distinct_id and $device_id on every event. Once you call identify(id), later events carry your real distinct_id and $user_id, while $device_id stays the same - it survives identify() so the device can still be recognized after login.

That shared $device_id is what lets Cohorly fold pre-login activity into the post-login profile automatically. The first event after identify() that carries a $device_id and $user_id you have not seen linked before is enough - no separate call is required. Cohorly retroactively links the anonymous id into the identified user's identity cluster: history already stored under the anonymous id is rewritten onto the real distinct_id, so a funnel or retention query spanning the login boundary counts one person, not two. This is a one-time, one-directional operation - once ids are linked, they cannot be split back apart. It only rewrites history this way when the absorbed id's own stored events prove it anonymous (every one stamped with a matching $device_id, none carrying a $user_id of its own) or when it has no history at all; an id that has itself ever identified a user keeps its history exactly where it is.

Server SDKs that pass an explicit distinct_id on every call (Node.js, NestJS, Python, PHP, Go) have no $device_id to signal with, so they use the same mechanism explicitly via alias(previousId, distinctId), which calls the ingestion API's /alias endpoint. It applies the same anonymity check to previousId: when previousId's own history is anonymous or empty, the link is retroactive just like the implicit case above. Otherwise - the usual case for a pre-signup id that already tracked real activity - alias() still succeeds, but only routes future events under previousId to distinctId; nothing already stored moves. See Event properties for the $device_id/$user_id keys and Ingestion API for /alias.

Multi-project isolation

Every table is keyed by project_id, and every query resolves a target project (from the token on ingestion, or the projectId parameter on the admin/query API). This keeps data for separate apps fully isolated within one Cohorly deployment.

PreviousQueries
NextEvent properties