Skip to content

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

flowchart TD A[Visitor opens widget] --> B{Known person_id?} B -->|No| C[New visitor flow] B -->|Yes| D[Load visitor data from Supabase] D --> E[Merge into session state] E --> F{Email known?} F -->|Yes| G[Skip email collection] F -->|No| H[Normal email capture flow]

Visitor Identification

Visitors are identified across sessions using:

  • person_id - PostHog's distinct_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:

  • visitors table - Profile data (email, enrichment_data)
  • conversations table - 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

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:

[Visitor Profile] Loaded data for person_id=abc12345...: fields=['email', 'company_name']

Privacy Considerations

  • Data is scoped to each site (no cross-site tracking)
  • person_id is 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