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.
The two identifiers
anonymous_id | user_id | |
|---|---|---|
| Where it comes from | the SDK mints it | your own authentication supplies it |
| Where it is kept | browser local storage, or a private file in the app | the same place, after the first identify |
| When it changes | only on reset | on the next identify, or on reset |
| Creates a profile | no | yes |
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 notidentifierForVendor(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:
- An empty user id is ignored, with a console warning.
- The user id is stored.
- If the user id differs from what was stored and an anonymous id exists, an
aliasmessage is enqueued first, carryingprevious_idset to the current anonymous id. - Then the
identifymessage is enqueued with the traits. - 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).
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",
});
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:
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:
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_Aandanon_B, both with the valueu_1. Both survive: a ReplacingMergeTree only collapses rows that share an anonymous id. - Every event carrying
user_idasu_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,timezoneandlanguage, 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_Atou_B, so the "it changed" condition is true and a second alias is enqueued. - The
anonymous_idhas not changed, because onlyresetmints a new one. So the second alias carriesprevious_idasanon_Xagain. - The identity map is ordered on
(tenant_id, anonymous_id), so the second row replaces the first. After the merge,anon_Xpoints atu_Band the fact that it once belonged tou_Ais 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:
- The user id is cleared, from memory and from storage.
- A new anonymous id is minted and stored.
- The session key is removed.
- 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.
import { createHmac } from "node:crypto";
export function userHashFor(userId) {
return createHmac("sha256", process.env.SEGMENTIC_IDENTITY_SECRET)
.update(userId)
.digest("hex");
}
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
}'
{"status":"ok","messages":[]}
{"status":"error","message":"user identity is not verified"}
Several exact behaviours to know:
- Only two endpoints want this hash:
POST /v1/inboxandPOST /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_idgets 400 with the messageuser_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.
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" }
}
]
}'
{"accepted":1}
Three ways this path differs on identity:
- You almost always have the
user_id, so send it and leaveanonymous_idout 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
identifythe 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_iddoes 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
acceptedand, when it applies,rejected. So you never seeinvalid_phoneorgenerated_message_idon this path even when they happened. - The time window here is a fixed 30 days, not the tenant's retention policy. Any older
timestampis 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
| Rule | What 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 in | pre-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 exception | the 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 number | user_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 session | you create one profile per session and the user count stops meaning anything |
Agree on one user_id format before two systems start writing | there is no operation that merges two profiles, and alias does not do it either |
Send email and phone as traits, not as the user_id | traits 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.