Skip to content
Docs
FA
Sign in
Start here
  • Overview
  • Quickstart
  • Concepts
Collect data
  • Designing events
  • Event dictionary
  • Placing events
  • Identity
  • Web SDK
  • Android SDK
  • Devices and push
  • Server to server
  • Product catalogue
  • Webhooks
Engage customers
  • Segments
  • Journeys
  • Transactional
  • Consent and caps
  • In-app and inbox
Analyze and export
  • Reports and exports
Developer reference
  • API reference
    • Ingest endpoints
    • Management API
  • Errors
  • Limits
  • OpenAPI
Developer tools
  • MCP server
  • Working with an agent
Privacy and changes
  • Personal data
  • Versioning

Identity: anonymous and signed-in users

How the history of somebody who has not signed in yet joins their account, and what happens when two people share one device.

Most people visit you several times before you know who they are. This page says what Segmentic does with that period: what is joined, what is not, and which actions cannot be undone.

IDENTIFIERS
Anonymous IDBefore sign-in
User IDYour stable key
Email or mobileVerified traits
SEGMENTICIdentity graphLink known identifiers without guessing
ONE CUSTOMER VIEW
Unified profileKnown customer
Device historyAll endpoints
Event historyBehavior over time
How anonymous ids, user ids and verified traits become one customer view

#The two identifiers

anonymous_iduser_id
Where it comes fromthe SDK mints ityour own authentication supplies it
Where it is keptbrowser local storage, or a private file in the appthe same place, after the first identify
When it changesonly on reseton the next identify, or on reset
Creates a profilenoyes

At least one of the two must be on every message, or the event is rejected with missing_identity. Both are capped at 256 bytes, and anything longer is id_too_long.

Every message goes onto the event bus under an identity key: the user_id when it is not empty, otherwise the anonymous_id. The reason is ordering: all of one person's messages must land on one partition so that the stateful consumers, profile updates, journey state and session stitching, see them in order without any cross-partition coordination.

The one fact the rest of this page follows from: a profile is keyed on user_id alone. The ingestor skips every event with an empty user_id before profiles are touched. An anonymous visitor has no profile at all.

#How the anonymous id is made and kept

The browser. The web SDK reads the key segmentic_anonymous_id from localStorage and mints one if it is absent. It uses crypto.randomUUID, falling back to a version 4 UUID built from crypto.getRandomValues, and on very old browsers to Math.random.

Before any of that, localStorage is probed with a real write rather than checked for existence. In Safari private mode, in hardened enterprise browsers, and when the origin's quota is exhausted, localStorage exists and throws on write. If the probe fails the SDK falls back to an in-memory store: the customer's site keeps working, but the anonymous id lives only as long as that page, and every reload is a new person.

Android and iOS. The same key, but as a file of that name inside a segmentic directory in the app's private storage, minted with UUID.randomUUID. It survives until the app is deleted.

Three things the anonymous id is not:

  • It is not a device id. The Apple SDK keeps a separate install id under segmentic_install_id, deliberately not identifierForVendor (which changes when the last app from a vendor is deleted) and deliberately not the advertising identifier (which is a privacy question the customer has to answer, not us).
  • It is not shared between two browsers or two devices. One person on a phone and a laptop has two anonymous ids.
  • It is not changed by identify. The same value rides every subsequent message.

Every message the SDK builds always carries anonymous_id, and carries user_id once it knows one.

#What identify does

On the client, in all three SDKs, in this order:

  1. An empty user id is ignored, with a console warning.
  2. The user id is stored.
  3. If the user id differs from what was stored and an anonymous id exists, an alias message is enqueued first, carrying previous_id set to the current anonymous id.
  4. Then the identify message is enqueued with the traits.
  5. On web and Android, scalar traits are cached locally so that in-app message targeting can match on them. The browser has only what it was given, not the warehouse; pretending otherwise would make every trait rule on every campaign silently false. The iOS SDK does not keep this cache.

The anonymous_id is not changed. Both the alias and the identify message carry the same anonymous id and the new user id.

Call identify twice with the same id and exactly one alias is produced. The comparison is against the stored value, not against memory for this run, so a page reload does not mint a duplicate alias either.

On the server, identify becomes an ordinary event named identify. Its traits are not stored on the events table; they go only to the person's profile. The profile has two rules: never erase (an event that does not mention a trait must leave it alone) and never regress (events replay out of order after a consumer restart, so an older event must not overwrite newer state).

Browser
import { init, identify } from "@segmentic/web";

init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
});

// After your own sign-in succeeds, and again on every page load
// while the session is still valid.
identify("u_88123", {
  phone: "09123456789",
  first_name: "حمید",
  loyalty_tier: "gold",
});
Android
Segmentic.identify(
    userId = "u_88123",
    traits = mapOf(
        "phone" to "09123456789",
        "first_name" to "حمید",
        "loyalty_tier" to "gold",
    ),
)

#alias and previous_id

identify sends the alias for you, so you rarely call this yourself. If you do, previous_id is required and a message without it is rejected with missing_previous_id.

One asymmetry you will not find in any limits table: unlike user_id and anonymous_id, which are rejected past 256 bytes, nothing checks the length of previous_id. Whatever you send rides intact into identity_map, and its only ceiling is the 5 MiB body cap.

What an alias message produces on the server is exactly this:

segmentic.identity_map
CREATE TABLE segmentic.identity_map (
    tenant_id    UInt32,
    anonymous_id String,
    user_id      String,
    linked_at    DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(linked_at)
PARTITION BY tenant_id
ORDER BY (tenant_id, anonymous_id);

One row. Plus the alias message itself, which lands in the events table as an event named alias like any other message.

Note that the sorting key is anonymous_id, not user_id. The whole of two people on one device follows from that single line.

alias does not join two user_id values. Whatever you put in previous_id lands on the anonymous side of the map, and nothing merges two profiles. There is no operation that merges two profiles.

#What the link is actually used for

identity_map has three consumers in the entire codebase: the insert, the per-person erasure path, and its own integration test. That is all.

The thing it is actually used for is erasure. When somebody asks to be forgotten, their event rows are deleted by user_id, but their pre-sign-in events carry an empty user_id and that pass cannot see them at all. The only route to those rows is the anonymous ids named in identity_map:

Erasing the pre-sign-in history
ALTER TABLE segmentic.events DELETE
WHERE tenant_id = ? AND user_id = '' AND anonymous_id IN (
    SELECT anonymous_id FROM segmentic.identity_map
    WHERE tenant_id = ? AND user_id = ?
) SETTINGS mutations_sync = 2;

And only after that, never before, is identity_map itself deleted. The order is the whole of it: the first version of this code deleted the map first, so the subquery matched nothing, the delete removed nothing, and it reported success. That person's browsing history from before they signed in, which is a large part of what "forget me" means, survived every erasure on the platform silently. An integration test caught it.

#What does not happen

This is the most important section on the page and it is written bluntly on purpose, because believing the opposite costs you an afternoon on a number that never comes right.

Historical anonymous event rows are never rewritten. No code updates events.user_id for rows whose anonymous id appears in the identity map. Those rows keep an empty user_id for ever.

The function that merges an anonymous profile into a known one exists and is never called. A repository-wide search for ApplyAlias finds its definition in profile/merge.go and call sites that are all inside merge_test.go, and nothing else.

An anonymous visitor never gets a profile at all. The ingestor skips every event with an empty user_id, so there is no anonymous profile to merge. That person's total_events, total_revenue and first_seen all begin at the moment they signed in.

No analytics query joins identity_map. The segment compiler, the funnel, retention and path reports, and the user timeline all read events.user_id directly.

Anonymous rows are excluded from both daily rollups, by WHERE user_id != ''.

The precise practical consequence: a funnel that begins with an anonymous product_viewed and ends with an identified order_completed does not join the two, because the compiler's event subquery groups on user_id and the anonymous row's is empty.

Wherever you read that "the anonymous history is connected to the user", the exact meaning is: the rows are kept and the link is recorded. It does not mean that reports attribute those rows to that user. Pre-sign-in funnels are not stitched.

So what to do: call identify as early as you legitimately can. If the user is still signed in from a previous session, call identify at the very start of the page load or app launch, before any other event, so that the session's events carry a user_id from the first message. An event sent with a user id is attributed correctly; the only thing that is not stitched is what came before it.

#Two devices, one person

Device A mints anon_A, device B mints anon_B. Both call identify("u_1").

  • Two alias messages are sent, one per device.
  • Two rows land in the identity map, keyed anon_A and anon_B, both with the value u_1. Both survive: a ReplacingMergeTree only collapses rows that share an anonymous id.
  • Every event carrying user_id as u_1, from either device, folds into one profile row. The counters accumulate across both devices.
  • The device facts, device_type, os_name, app_version, push_provider, city, timezone and language, describe the latest session, not the union of the two. They update only when the event's timestamp is not older than the last-seen time, and an empty incoming value never replaces a known one. An event sent from a background thread with no device context must not make a reachable user unreachable: that person would drop out of every push campaign with nothing in any log to explain it.
  • Both devices' pre-sign-in rows remain unattributed.
  • Erasure handles this case correctly: it deletes anonymous events for every anonymous id the map associates with that user.

#Two people, one device

The device mints anon_X. The first person calls identify("u_A"). Later, without reset being called, a second person calls identify("u_B").

  • The stored user id changes from u_A to u_B, so the "it changed" condition is true and a second alias is enqueued.
  • The anonymous_id has not changed, because only reset mints a new one. So the second alias carries previous_id as anon_X again.
  • The identity map is ordered on (tenant_id, anonymous_id), so the second row replaces the first. After the merge, anon_X points at u_B and the fact that it once belonged to u_A is gone.

The consequence for erasure: if u_A later asks to be forgotten, the lookup no longer finds anon_X, so u_A's pre-sign-in events are not deleted. This is a real gap, it does not reverse, and no test covers it. The only thing that prevents it is calling reset on sign-out.

What stays sound: u_A and u_B are two separate profiles and each keeps its own counters. Both people's identified events remain correctly attributed, because every event carried the user id that was current when it was enqueued.

#reset

Call it on sign-out. Every time.

What it does:

  1. The user id is cleared, from memory and from storage.
  2. A new anonymous id is minted and stored.
  3. The session key is removed.
  4. On web only: the stored campaign attribution is cleared. On a shared computer, the next person's purchase must not be credited to the message the last one received. Android has no client-side campaign replay, so it has nothing to clear.

What it does not do, in all three SDKs:

  • It does not clear the queue and it sends nothing. Buffered messages still go out carrying the user id they were built with, which is the correct behaviour.
  • It does not clear the locally cached traits. Until the next identify, in-app targeting rules still match against the previous person's traits.
  • It does not clear the "has been here before" flag, deliberately. Signing out does not make somebody a new user, and a "first launch" campaign that reappears after every sign-out would be a bug the customer hears about from their users.
  • It sends nothing to the server. There is no such thing as a reset on the server: only the five message types are accepted, and there is no unlink or de-alias operation.

reset is not a consent control. That is optOut, which stops collection and clears the buffer, because honouring an opt-out only for future events while quietly delivering what was already captured is not an opt-out.

#user_hash, and what it is for

The write key is public. It ships inside the customer's own page and anyone can read it out of the source. For writes that is acceptable: the worst a stranger can do with one is add noise to the customer's own data, which is visible and repairable.

The in-app inbox is the first read. Its rows carry the message body and the personalised discount code generated for one named customer, and "give me the inbox of user 91372" behind a key anyone can read out of the page source is not an endpoint that can exist.

So two separate checks run, because they answer different questions. The write key says which tenant's data is in play, and says nothing about who is asking. The hash says the customer's own backend authenticated this person. Only the second one stands between "show me my messages" and "show me everyone's".

The formula, exactly:

user_hash = hex(hmac_sha256(identity_secret, user_id))

The identity_secret belongs to your tenant and it must never reach a browser. Your backend computes the hash at sign-in and hands it to the SDK, which sends it with every inbox request.

Obtaining it is not self-service, and you should plan around that: no HTTP route writes this secret and no panel screen exists for it. The only thing that sets it on a tenant is the adminctl command-line tool, which means a Segmentic operator. Until that has happened, the in-app inbox answers you with 403 and nothing else.

On your server, at sign-in
import { createHmac } from "node:crypto";

export function userHashFor(userId) {
  return createHmac("sha256", process.env.SEGMENTIC_IDENTITY_SECRET)
    .update(userId)
    .digest("hex");
}
Reading the inbox
curl -X POST https://in.segmentic.net/v1/inbox \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer wk_seg_..." \
  -d '{
    "user_id": "u_88123",
    "user_hash": "3f6c1d0a9b8e4725c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7",
    "limit": 20
  }'
Response when there is nothing waiting
{"status":"ok","messages":[]}
Response when the hash is wrong or missing
{"status":"error","message":"user identity is not verified"}

Several exact behaviours to know:

  • Only two endpoints want this hash: POST /v1/inbox and POST /v1/inbox/ack. Every other collector path is a write, and a write key is enough for those.
  • It fails closed, though not in the way you would expect. Registering the two routes is an install-wide decision, not a per-tenant one: on any install with the inbox configured, both routes exist for everybody. What a tenant with no identity secret gets is a 403 on every request. There is no unverified mode.
  • A body with no user_id gets 400 with the message user_id is required, before any identity check runs.
  • The comparison is constant time. The endpoint is open to the world, and a byte-at-a-time compare leaks the expected value one character at a time to anyone patient.
  • An upper-case hash is accepted; the proof is trimmed and lower-cased before the comparison.
  • A wrong hash and a missing hash both get 403 with the identical body. Distinguishing them would turn this into an oracle for which user ids exist.
  • Rotating the secret invalidates every hash your backend has already handed out, which signs your whole app out of its inbox until you redeploy. Not something to do by accident.

#Identity when you send from your own server

From your own backend, events go to POST /v1/events on api.segmentic.net with a management key holding profile.write. A successful response is 202, not 200: the events are queued, not stored, and become queryable seconds later. Saying 200 would invite a caller to read them back immediately and conclude they were lost.

Why profile.write and not a new permission: that is what this does, it writes to people's profiles and their event history, and inventing a second name for the same capability would let somebody grant one believing they withheld the other.

An event from your server
curl -X POST https://api.segmentic.net/v1/events \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk_seg_..." \
  -d '{
    "events": [
      {
        "type": "track",
        "user_id": "u_88123",
        "event": "order_completed",
        "timestamp": "2026-08-07T10:02:41.000Z",
        "properties": { "order_id": "A-100294", "revenue": 2450000, "currency": "IRR" }
      }
    ]
  }'
Response, with status 202
{"accepted":1}

Three ways this path differs on identity:

  • You almost always have the user_id, so send it and leave anonymous_id out entirely.
  • This path normalises with no IP address and no User-Agent, because it is a server-to-server call and attributing a recipient's city from your data centre address would put every one of your users in one place. So there is no geolocation, no browser, no bot flag and no device derivation on this path.
  • Your server does not know the browser's anonymous id and cannot know it. If you want an anonymous session and your server events to meet, the alias has to come from the browser, which means from the identify the SDK calls.

And three things that are not about identity but will catch you here anyway, because all three are silent:

  • This path does not de-duplicate. message_id does not protect you. Post the same batch twice and it is written twice. The 48-hour de-duplication belongs to the collector, and this door goes past it.
  • Warnings are computed and thrown away. The response body carries accepted and, when it applies, rejected. So you never see invalid_phone or generated_message_id on this path even when they happened.
  • The time window here is a fixed 30 days, not the tenant's retention policy. Any older timestamp is silently clamped to the edge of it and the answer is still a successful 202. That is why history is not migrated through this door.

#Rules that buy you time later

RuleWhat it prevents
Call identify the first moment you know who the user is, and on every page load or app launch for someone still signed inpre-sign-in history is not stitched, so the later you call it the more of your data stays ownerless
Call reset on sign-out, without exceptionthe next person on the device inherits the previous person's anonymous id, and the previous person's link is erased for ever
Use your own primary key for user_id, not an email and not a phone numberuser_id is the profile's primary key and there is no rename operation. Somebody who changes their email gets a second profile
Never use a value that changes each sessionyou create one profile per session and the user count stops meaning anything
Agree on one user_id format before two systems start writingthere is no operation that merges two profiles, and alias does not do it either
Send email and phone as traits, not as the user_idtraits are where they belong and where they are normalised; a user_id shows up in exports and in the timeline URL

Next: designing events covers what to send and what to call it, and the event dictionary has the list of traits you send with identify.

PreviousPlacing eventsNextWeb SDK

On this page

  • The two identifiers
  • How the anonymous id is made and kept
  • What identify does
  • alias and previous_id
  • What the link is actually used for
  • What does not happen
  • Two devices, one person
  • Two people, one device
  • reset
  • user_hash, and what it is for
  • Identity when you send from your own server
  • Rules that buy you time later

Segmentic

This page is written from the code