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.
tsinterface Project {id: number;name: string;token: string; // UUID, authenticates ingestioncreated_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.
| Column | Type | Notes |
|---|---|---|
project_id | integer | Owning project. |
event | text | Event name. |
distinct_id | text | User/device this event belongs to. |
time | bigint | Unix milliseconds. |
$insert_id | text | Idempotency key for deduplication. |
properties | jsonb | Arbitrary 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.
tsinterface UserProfile {distinct_id: string;properties: Record<string, unknown>; // from $set / $set_once / $add / $unsetfirst_seen: number | null; // unix mslast_seen: number | null; // unix msevent_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.