Event properties
Every event carries a properties object. A few keys are special - they drive identity, timing, deduplication, and attribution - while everything else is free-form data you define. The SDKs set the special keys for you; when calling the HTTP API directly, set them yourself.
Required and reserved keys
| Key | Type | Meaning |
|---|---|---|
distinct_id | string | Required. Identifies the user or device the event belongs to. |
time | number | Unix milliseconds. Optional over HTTP (defaults to server receipt time); the SDKs always set it at capture time. |
$insert_id | string | Idempotency key used for deduplication (see below). |
$lib | string | Which SDK produced the event: core, web, ios, etc. |
$device_id | string | Set by the client SDKs. The persisted anonymous device id (see below). |
$user_id | string | Set by the client SDKs, only once the visitor is identified (see below). |
token | string | Per-event project token. Overrides the header and is stripped before storage. |
$distinct_id_before_identity_fix | string | Server-stamped, not client-sent. Present on any event whose distinct_id was rewritten by an identity link - the id the row was originally stored under (see below). |
Device and user identity
Client SDKs (@cohorly/web, React, Next.js, React Native, iOS, Android) stamp
two identity keys alongside distinct_id, and the pair is what lets you tell a
browser apart from the person using it:
$device_idis the anonymous id minted the first time the SDK runs and persisted from then on. It survivesidentify(), so pre-login and post-login activity stay attributable to the same device.reset()mints a new one - that is the "different person, same browser" case.$user_idis only present once you have calledidentify(). Its absence is meaningful: an event without it came from a visitor you have not identified yet.
A caller-supplied value always wins over the SDK's, on both keys. Installs
predating the anonymous-id flag are treated as anonymous rather than being
given a fabricated $user_id.
Server SDKs do not set either key - there is no device to speak of, and every
call already carries an explicit distinct_id.
The first post-identify event that carries a $device_id/$user_id pair
Cohorly has not linked yet is enough to fold the device's earlier, anonymous
history onto the identified user - no separate call is required, and this
happens whether or not your SDK also calls /alias (see
Identity). The rewrite stamps
$distinct_id_before_identity_fix on every event it moves, so the original
id an event was stored under is always recoverable even after the link.
Deduplication with $insert_id
Every SDK-generated event gets a unique $insert_id (a UUID). Because
retries can resend a batch that already landed, the server treats
$insert_id as an idempotency key: an event with an id that already exists
for the project is not inserted twice. This makes the at-least-once delivery
of the SDK queues safe - a flush that fails, is retried, and then succeeds
twice still produces exactly one stored event.
json{"event": "Purchased","properties": {"distinct_id": "user_123","time": 1751600000000,"$insert_id": "b1f2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d","amount": 49}}
Time is unix milliseconds
time is stored as a bigint of unix milliseconds. If you send seconds (a
common mistake) your events will land in 1970. When omitted over HTTP, the
server stamps receipt time; the SDKs stamp the moment track() is called so
offline/queued events keep their true timestamp.
Auto-captured properties
The SDKs enrich every event with context so you do not have to. What gets added depends on the platform:
| SDK | Auto-added |
|---|---|
@cohorly/web | $browser, $os, $current_url, screen size, $lib = web |
CohorlySwift (iOS) | $lib = ios, $os, $os_version, $model, $screen_width/$screen_height |
| Core / React Native | distinct_id, time, $insert_id, $lib, plus your super properties |
Custom properties
Any keys beyond the reserved ones are stored verbatim in the jsonb
properties column and become available for filtering and breakdowns in
queries. Use consistent naming and value types per key so
segmentation stays clean.
tscohorly.track("Item Added", {sku: "A-100", // stringprice: 29.0, // numbercurrency: "USD", // stringgift: false, // boolean});