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.