Returning Visitor Recognition¶
Overview¶
Returning Visitor Recognition persists visitor profile data across sessions, enabling personalized experiences for returning visitors. When a visitor comes back, the system remembers their email, company, and other information.
How It Works¶
Visitor Identification¶
Visitors are identified across sessions using:
person_id- PostHog'sdistinct_id(persisted in browser)site_domain- Scoped to each site
The combination (site_domain, person_id) uniquely identifies a visitor.
Data Persistence¶
What's Stored¶
| Field | Source | Persists |
|---|---|---|
email |
Captured in conversation | ✅ |
company_name |
Enrichment or inferred | ✅ |
domain |
Enrichment | ✅ |
sector |
Enrichment or inferred | ✅ |
sub_sector |
Enrichment or inferred | ✅ |
company_description |
Enrichment | ✅ |
lifetime_interest_score |
Interest signals | ✅ |
lead_qualification_data |
Form field extraction | ✅ |
Storage Location¶
Data is stored in Supabase:
visitorstable - Profile data (email, enrichment_data)conversationstable - Session state snapshots
Loading on Session Start¶
When a new session begins (chatbot._prepare_query_state):
# Load visitor profile data
existing_visitor_data = await storage.get_visitor_profile_data(site_domain, person_id)
# Merge with current session (only missing fields)
for field in ["email", "company_name", "domain", "sector", "sub_sector"]:
current_value = getattr(current_profile, field, None)
if not current_value or current_value == "unknown":
profile_updates[field] = existing_value
On a consent-gated site (qualification.identification_requires_consent), a turn without
the host's consent (identification_allowed=False) loads none of this, and
store_conversation_turn does not write the turn's profile back over enrichment_data.
Any new data keyed by person_id must follow the same gate (IX-5465).
A company found by a session-based provider (Snitcher) is reloaded with store_ip_hash=False,
so a later turn cannot add the visitor's IP to ip_hash_to_account (IX-5956).
Stored lead answers are reloaded only for questions the client still asks: keys of
qualification.profiling.field_definitions, plus email (written by the booking flow).
An answer to a removed question stays in visitors.lead_qualification_data but is not
read, so re-adding the question brings it back (IX-5568: Pennylane's retired employee
bands were read as a company size the visitor never gave).
A backoffice playground turn (deployment_target="backoffice-playground") loads none of it
either. One staff browser runs many test conversations under the same person_id, so
recognising it would replay the tester's earlier answers as a profile the visitor "gave"
(IX-5568). Real widget visitors are still recognised.
Impact on Features¶
Booking Flow¶
If email is already known: - Bot skips asking for email - Immediately confirms and redirects - Faster conversion for returning visitors
# In booking_handler.py
has_email = _has_valid_email(visitor_profile)
if has_email:
# Generate confirmation, skip email collection
Interest Signals¶
Lifetime interest score persists: - High-interest visitors get appropriate treatment - Score accumulates across sessions
if existing_lifetime_score > 0:
deserialized.interest_signals_state.lifetime_interest_score = existing_lifetime_score
Lead Qualification¶
Form field values persist: - CTA URLs can be personalized immediately - No need to re-ask qualification questions
Key Files¶
Backend¶
| File | Purpose |
|---|---|
ixchat/chatbot.py |
Loads and merges visitor data on session start |
ixdata/clients/conversation_storage.py |
get_visitor_profile_data(), get_lifetime_interest_score() |
ixchat/nodes/visitor_profiler.py |
Updates profile during conversation |
Database Queries¶
Load Visitor Profile¶
response = await (
client.table("visitors")
.select("email, enrichment_data")
.eq("site_domain", site_domain)
.eq("person_id", person_id)
.execute()
)
Load Lifetime Interest Score¶
response = await (
client.table("conversations")
.select("state")
.eq("site_domain", site_domain)
.eq("person_id", person_id)
.order("created_at", desc=True)
.limit(1)
.execute()
)
Debugging¶
Look for these log patterns:
📋 Loaded visitor profile data from Supabase: fields=['email', 'company_name']
📋 Loaded lifetime_interest_score=85 from previous sessions
📋 Loaded lead qualification data: fields=['company_size', 'industry']
And in conversation storage:
Privacy Considerations¶
- Data is scoped to each site (no cross-site tracking)
person_idis a PostHog ID, not PII- Email is only stored when explicitly provided
- Data can be deleted on request
Testing¶
poetry run pytest packages/ixchat/tests/test_visitor_profiling.py -v
poetry run pytest packages/ixchat/tests/test_merge_visitor_profiles.py -v
Related¶
- Visitor Enrichment - How company data is initially captured
- In-Chat Booking Email - Email capture and booking flow
- Interest Signals - How interest scores work