Stitching anonymous activity to a user once they sign up
An anonymous id becomes a user id exactly once, at signup. The alias call, the identify call, and the mistake that splits one person into three.
Someone browses your site for a week, then signs up. Without stitching, your analytics has an anonymous visitor who vanished and a new user who appeared from nowhere, and the question “where did our paying customers come from” has no answer. POST /v1/analytics/alias on Infrai merges an old distinct_id into a new one, and POST /v1/analytics/identify attaches traits to it.
Call alias once, at the moment identity changes. Calling it twice, or later, is how one person becomes three.
Alias at the moment of signup
curl -sS -X POST "https://api.infrai.cc/v1/analytics/alias" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"old_id": "anon_9f2c41bd7a9e8c0b",
"new_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"idempotency_key": "alias-au_usr_lMmXGySJeGVM1xicqHGJaJbB"
}'
{
"ok": true,
"data": {
"old_id": "anon_9f2c41bd7a9e8c0b",
"new_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"merged": true
}
}
old_id is the anonymous identifier your front end has been sending — a cookie value, a device id, anything stable for that browser. new_id is the real user id, and from now on it’s the only one you use.
The idempotency_key keyed on the user id is what makes a retried signup safe. Without it, a double-submitted registration aliases twice, and the second call is either a no-op or a merge you didn’t intend.
Then identify with traits
curl -sS -X POST "https://api.infrai.cc/v1/analytics/identify" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"distinct_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"traits": {"plan": "trial", "signup_source": "docs", "country": "GB", "seats": 1},
"idempotency_key": "identify-au_usr_lMmXG-v1"
}'
{
"ok": true,
"data": { "distinct_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB", "traits_merged_count": 4 }
}
Traits merge rather than replace, so a later identify adding plan: "pro" updates that one trait and leaves the rest. traits_merged_count confirms how many were applied.
Traits are the current state of a person; events are things that happened.
Putting last_login_at in traits is fine, because it describes the person as they are now and gets overwritten each time. Putting logged_in there is a category error that costs you the whole history: a trait keeps one value, so recording logins as a trait means you can never count them, segment by them, or ask when they stopped — all of which are the questions you would eventually want, and all of which an event answers for free.
The signup handler, in order
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def alias(anonymous_id: str, user_id: str) -> dict:
resp = SESSION.post(f"{API}/v1/analytics/alias",
json={"old_id": anonymous_id, "new_id": user_id,
"idempotency_key": f"alias-{user_id}"},
timeout=20)
return resp.json().get("data", {})
def identify(user_id: str, traits: dict, version: str = "v1") -> dict:
resp = SESSION.post(f"{API}/v1/analytics/identify",
json={"distinct_id": user_id, "traits": traits,
"idempotency_key": f"identify-{user_id}-{version}"},
timeout=20)
return resp.json().get("data", {})
def track(event: str, user_id: str, properties: dict | None = None) -> dict:
resp = SESSION.post(f"{API}/v1/analytics/track",
json={"event": event, "distinct_id": user_id,
"properties": properties or {}},
timeout=20)
return resp.json().get("data", {})
def on_signup(anonymous_id: str | None, user_id: str, traits: dict) -> dict:
"""Order matters. Alias FIRST, so the events that follow attach to a person
whose history already includes the anonymous session; identify second; track
the signup last. Tracking before aliasing files the signup under an id you are
about to merge away, which is recoverable in the data and confusing forever."""
merged = alias(anonymous_id, user_id) if anonymous_id else {"merged": False}
identify(user_id, traits)
track("signup_completed", user_id, {"source": traits.get("signup_source")})
return {"user_id": user_id, "history_merged": bool(merged.get("merged"))}
def on_group_join(user_id: str, org_id: str, org_name: str, seats: int) -> dict:
"""B2B products need the company as well as the person."""
resp = SESSION.post(f"{API}/v1/analytics/group",
json={"distinct_id": user_id, "group_type": "organisation",
"group_key": org_id,
"traits": {"name": org_name, "seats": seats}},
timeout=20)
return resp.json().get("data", {})
if __name__ == "__main__":
print(on_signup("anon_9f2c41bd7a9e8c0b", os.environ["USER_ID"],
{"plan": "trial", "signup_source": "docs", "country": "GB", "seats": 1}))
The mistake that splits one person into three
Aliasing at the wrong moment, or repeatedly, is the failure mode — and it’s not recoverable after the fact.
Alias exactly once per user, at the transition from anonymous to known. Do not alias on every login: a returning user already has their real id, and aliasing a fresh anonymous id into them on each visit produces a chain of merges whose history nobody can reason about. Do not alias two known users together, either — that’s an account merge, and analytics is not where you perform one.
| Moment | Call |
|---|---|
| First anonymous visit | track with the anonymous id |
| Signup or first login on this device | alias once, then identify |
| Later logins | nothing — use the user id |
| Trait changes (plan, seats) | identify again |
| Joins a company | group |
| Two accounts turn out to be one person | your own merge, not alias |
Where the ids come from
The anonymous id is yours: a cookie or local-storage value your front end generates and sends to your backend with each event. The user id should be the one your auth surface already issues — GET /v1/auth/session/verify/{session_id} returns the user_id, and using that same value as distinct_id means your analytics and your identity agree without a mapping table.
That’s the quiet benefit of one credential: the id that authenticates the request is the id the event is filed under, so “which users did X” is a join you never have to build.
Limitations
Aliasing is one-way and, once merged, not something you can split apart — so a wrong alias is permanent in the data. There’s also no automatic cross-device stitching: a user on a phone and a laptop has two anonymous ids, and only the devices where they sign in get stitched.
PostHog and Mixpanel both handle identity resolution with more machinery, including merge histories and reverse-alias tooling, which matters if your identity graph is complicated. And there’s no UI here for inspecting a person’s timeline — POST /v1/analytics/query/events filtered by distinct_id is the closest thing, and you render it yourself.
Alias, identify and group bill per call at rates live in GET /v1/discovery/analytics.identify (verified 2026-09-21), low enough that the discipline matters far more than the cost, and drifting downward as vendor contracts improve.