Docs Sending data in
Identity resolution
Stitching anonymous visits to a known account, and what happens when two people share a device.
Lyraflow stitches a device’s anonymous activity to the person it belongs to, and lets you merge two people that turn out to be the same one. Filtering and segmentation are built on top of it — see Segments below.
Binding a device to a person
Send /v1/identify with both anonymous_id and user_id to bind the device
to the person from that moment on:
curl -i http://localhost:3000/v1/identify \
-H 'content-type: application/json' \
-H "x-lyraflow-write-key: $LYRAFLOW_WRITE_KEY" \
-A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' \
-d '{
"message_id": "3fa5e3fd-3c8b-4b8b-9b8e-6e3f9e5b8a01",
"anonymous_id": "visitor-1",
"user_id": "user-42",
"traits": { "plan": "trial" }
}'
The first time a device is bound, every event ever recorded under that
anonymous_id — before this identify call and after it — resolves to that
person, not just events going forward. If the device is later bound to a
different person (a shared computer, a re-identified session), the
timeline splits at that second identify call’s own timestamp: events
before it keep the first person, events from it onward follow the second.
Resolution always follows the event’s own (clamped) timestamp, never the time
the identify request happened to arrive at the server.
That time-split describes how an event is resolved to a person: it is the
rule applied row by row to the events table, and it is what a query over
those events sees. It is not how GET /v1/persons/:id counts a profile —
that read takes a simpler union over every id, with no timestamp condition,
and on a shared or rebound device the two deliberately disagree. Reading a
person below states exactly how.
Sizing note: every identify with an anonymous_id writes a row. That
includes the repeat identify a logged-in browser typically sends on every
page load. Repeats are not deduplicated: if you omit timestamp, each call
is stamped with server receipt time, so no two land on the same instant and
nothing collapses them. At 100k identified pageviews/day that is 100k rows
per day in Postgres’ identity_bindings, growing without bound, and each row
is also carried into the ClickHouse identity dictionaries — which reload in
full every 5–15 seconds. If you send high identified volume, expect this to
be the fastest-growing table in your Postgres, and watch dictionary reload
time alongside it.
This is a known cost, not an oversight. A write-side suppression
(skip the insert when the device is already bound to this person) was built
and then reverted: it is not safe against a late, out-of-order identify,
which can silently and permanently hand one person’s later activity to
another. Correctness won. A safe fix belongs in the range derivation rather
than the write path; see packages/server/src/identity/bindings.ts for the
full reasoning and the reproduction.
Practical mitigation today: call identify once per session rather than once
per page view. Alternatively, send a stable explicit timestamp for
repeats of an unchanged binding — an identical
(anonymous_id, user_id, timestamp) triple collapses onto the existing
row and adds nothing. A timestamp that advances on every call does not
help; it is the repetition, not the presence, of the value that collapses
the write.
Keep that stable value inside the 24-hour clamp window. Bindings are written at the event’s clamped timestamp, so a fixed value — a session start time, say — stops collapsing once it is more than 24 hours old: the clamp rewrites it to a boundary computed from the current time, which moves on every call, and each repeat writes a fresh row again. A session pinned at login and still open two days later is the ordinary way to hit this. Re-pin the value at least daily, or use the once-per-session call, which has no such expiry.
Merging two people
POST /v1/alias merges two known people — an id migration, a duplicate
signup — under the server key, not the write key: aliasing mutates
identity for the whole project, so it must not be reachable with the public,
browser-shipped key.
curl -i http://localhost:3000/v1/alias \
-H 'content-type: application/json' \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' \
-d '{ "from_user_id": "user-42-old", "to_user_id": "user-42" }'
Answers 200 with {"status":"merged"}, or {"status":"noop"} if the two
ids already resolve to the same person. Aliasing is not reversible —
there is no unalias, and merging A into B and then B into
A lands on noop rather than undoing the first merge. 400 for a missing
or empty from_user_id/to_user_id; 401 for a missing or invalid server
key. 503 with retry-after: 5 — the merge runs in a SERIALIZABLE
transaction, so two merges touching the same alias group at the same moment
can make Postgres abort one of them (40001); the server answers 503
rather than pretending the merge happened. Retry the identical request — it
is idempotent, and a merge that already succeeded answers noop.
Reading a person
GET /v1/persons/:id returns one person’s stitched profile — also server-key
only:
curl -i "http://localhost:3000/v1/persons/user-42" \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
{
"person_id": "user-42",
"ids": ["user-42", "visitor-1"],
"devices": ["visitor-1"],
"first_seen": "2026-08-01T12:00:00.000Z",
"last_seen": "2026-08-06T09:30:00.000Z",
"events": 14,
"traits": {"plan": "pro"},
"traits_num": {},
"trait_total": 1,
"traits_withheld": false
}
devices is the subset of ids that are device ids rather than ids you
assigned — ids alone cannot say which is which, and the profile’s identity
header shows the two as different things.
traits, traits_num and trait_total are the same shape a segment member
row already carries, capped the same way. traits_withheld: true does not
mean this person has no traits — it means a deletion boundary exists for
them, and a trait cannot be split at it. 004_person_traits.sql stores
traits as argMax states with the timestamp discarded, so unlike an event a
trait carries nothing to compare against suppressed_at; the read agrees
with the export’s own refusal (see Exporting a person below) rather than
inventing a second answer, and returns empty maps with traits_withheld: true
in that case instead of ones that merely look empty.
:id can be any id that has ever pointed at this person — a device id, the
current canonical id, or an id since merged away by /v1/alias — and the
response always reflects the current, merged state. This read goes straight
to Postgres rather than through ClickHouse’s identity dictionaries, so it
sees a binding or a merge the instant it is written, with no refresh delay.
404 for an id nothing has ever recorded an event under; 401 for a missing
or invalid server key.
If :id is a device id that has been bound to more than one person over
time — a shared laptop — it resolves to the person bound most recently,
and the profile you get back is that person’s. There is no single right
answer for a shared device, so this one is picked deliberately: it is the
device’s current owner.
This read is time-split, matching event resolution. first_seen,
last_seen and events are computed the same way Binding a device to a
person (above) resolves an event: a device that has been shared or rebound
between two people splits at the rebind, and each profile counts only the
events that fell inside its own window on that device. An event that carries
a user_id of its own belongs to that person regardless of which device it
sits on, even during a stretch where the device itself was bound to someone
else. ids is unaffected by any of this — it stays the full set of ids ever
associated with the person, with no timestamp condition, because there is no
notion of an id being “in force” only some of the time.
A person’s windows are their devices multiplied by however many times each
was rebound, which has no fixed bound. Past 200 device windows the request is
refused with 400:
{
"error": "person_history_too_fragmented",
"detail": "this person spans 214 device windows, above the limit of 200"
}
rather than silently widening the query to fit — widening a window is exactly how the old union behaviour would come back.
This page is the Identity resolution section of the product
README at v0.15.0. It is generated from that file rather than
written here, so a correction belongs upstream.