Identity resolution: stitching anonymous visitors to emails to purchases
One person, three devices, two anonymous ids, one email, five purchases. The rules that turn that mess into a single journey — and the merge bugs that make it subtly wrong.
visitor(anonymous_id) → person(email) ← visitor(anonymous_id)
Identity resolution means binding many anonymous visitor records to one person. The email is the join key, the binding is retroactive, and getting the merge rules right is the hardest part of any attribution system.
Merges must be idempotent and order-independent, because events arrive late, out of sequence, and twice.
Attribution is often described as a tracking problem. It is mostly a joining problem, and this is the join.
The data model
Three tables and two relationships:
visitors(id, anonymous_id, first_touch_*, last_touch_*, person_id)
people(id, email, identified_at, identified_via)
events(id, visitor_id, person_id, name, source, occurred_at, properties)
- A visitor is a browser. One anonymous id, one device, one browser profile.
- A person is a human. One email.
- A visitor gains a
person_idat the moment of identification, and so do all of its past events.
That last clause is the whole feature. Without retroactivity, identification tells you only what happened afterwards — and the acquisition always happened before.
The moment of identification
{
"source": "server",
"category": "identification",
"name": "signup_started", // the cause
"email": "hello@example.com",
"dedup_key": "user_4821"
}
Three things happen:
- Find or create the person by normalised email — lowercased, trimmed.
- Bind the visitor to that person.
- Backfill every event that visitor already produced with the person id.
Step 3 is where the acquisition data comes from. A visitor who arrived on a Facebook ad two weeks ago now has a name, and the Facebook campaign now has a customer.
The merge cases
Same person, second device. They identify on their laptop having previously identified on their phone. Two visitors, one person. Both histories belong to them, and the person’s first touch becomes the earlier of the two.
Different person, same device. A shared computer, or a colleague using a demo laptop. The visitor’s person_id moves to the most recent identifier. Both people keep their own events. This is rare and rarely material; over-engineering it is a common waste of a week.
Same person, two emails. work@ and personal@ produce two people unless something links them. There is no reliable automatic solution, and the honest answer is to accept some duplication rather than guess.
Identification before any events. Someone arrives via a link that identifies them immediately. The person exists with a single visitor and no prior history. Fine.
Events arriving after identification, from an unbound visitor. A queued client event lands late from a visitor that was bound in the meantime. It must inherit the person id.
That last case is the one that silently breaks homegrown systems. Which leads to the rule that matters most:
Merges must be idempotent and order-independent
Events arrive late. Webhooks retry. Job queues replay after a deploy. A client-side event can arrive minutes after the server-side one it belongs with.
So the merge must produce the same final state regardless of the order it sees things in, and running it twice must be a no-op:
def identify(anonymous_id, email, via:)
person = Person.find_or_create_by!(email: email.downcase.strip)
visitor = Visitor.find_or_create_by!(anonymous_id: anonymous_id)
return if visitor.person_id == person.id # already bound — no-op
visitor.update!(person_id: person.id)
visitor.events.where(person_id: nil).update_all(person_id: person.id)
person.update!(identified_via: via) if person.identified_via.blank?
end
The return if line is what makes a retried webhook harmless. The where(person_id: nil) is what makes the backfill safe to run repeatedly.
First touch after a merge
Once two visitors belong to one person, which campaign gets credit?
The earliest first touch across all of them. A person’s acquisition is the first time they arrived, on any device. Anything else means a customer’s origin changes depending on which device they happened to log in on second, which is obviously wrong the moment you say it out loud.
Why the cause is worth recording
identified_via — newsletter, demo_requested, signup_started, checkout — costs one column and changes what the data can answer.
“We identified 4,000 people this quarter” is a vanity metric. “We identified 340 people via demo requests, and 62% of them came from organic search” is a plan.
Only your server knows the cause. A browser-side identify(email) call knows that an email arrived and nothing about why.
Where it pays off
Every purchase by an identified person inherits their first-touch campaign. Month twelve’s renewal is still credited to the ad that acquired them in month zero — no window, no cookie, no expiry involved.
That is how you get cohort LTV by acquisition campaign, which is the report that actually tells you which channel to scale.
Frequently asked questions
What is the join key?
The email address, normalised — lowercased and trimmed. It is the only identifier a customer supplies deliberately, which is why it survives device changes, browser changes and cookie expiry.
What happens to the anonymous history?
It is bound to the person retroactively. Every session that visitor ever had becomes part of that person's journey, which is what makes acquisition attribution possible at all.
What if two people share a device?
You get one visitor record bound to two people. Most systems keep the most recent binding for the visitor while both people retain their own history. It is rare and rarely material.
What if one person uses three devices?
Three visitor records, one person, once each has identified. That is the intended case and it works well — it is also the only reliable cross-device join left.
Which touch wins after a merge?
The earliest first touch across all merged visitors. A person's acquisition is the first time they arrived, on any device.
See the merged journey
The journey explorer shows every session for one person — including the anonymous ones from before they identified.
Stop guessing which ad made the sale.
Flowsk Signals stitches the anonymous click to the email to the purchase — first-party, server-side, de-duplicated. One snippet, $29/mo, and every conversion comes with a receipt you can inspect.