Visitor Enrichment¶
Overview¶
Visitor Enrichment identifies company information about website visitors when the widget loads, before any conversation begins. This enables personalized experiences from the first interaction.
How It Works¶
Feature Flag¶
Enrichment is controlled per-site via enrich_all_visitors:
Checked via resolve_config_for_domain(site_name) and is_enrich_all_visitors_enabled(resolver).
API Endpoint¶
POST /api/visitor/impression
Request¶
{
"siteName": "example.com",
"sessionId": "sess_abc123",
"personId": "posthog_distinct_id",
"visitorIp": "1.2.3.4",
"browserRevealData": {
"ip": "1.2.3.4",
"userAgent": "..."
},
"snitcherSessionId": "snitcher_id"
}
Response¶
{
"status": "accepted",
"enrichment_triggered": true,
"message": "Enrichment started in background"
}
Status Values¶
| Status | Meaning |
|---|---|
accepted |
Enrichment triggered in background |
already_enriched |
Visitor already enriched (deduplication) |
skipped |
Feature disabled for this site |
error |
An error occurred |
Key Files¶
Backend¶
| File | Purpose |
|---|---|
ixchat/enrichment/impression_enricher.py |
Main enrichment logic |
ixchat/enrichment/unified_enricher.py |
Multi-source enrichment pipeline |
ixchat/enrichment/redis_cache.py |
Deduplication cache |
api/search/routes/visitor.py |
API endpoint |
Enrichment Sources (the cascade)¶
Account enrichment is a cascade: sources are tried in priority order and the
first one that returns a completed match wins — the rest are skipped for that
run (unified_enricher.py, source_config.py).
| Source | Role |
|---|---|
redis_cache |
Recent enrichment for this person (dedup, no external call) |
supabase_lookup |
Existing enrichment already stored for the visitor/account |
browser_reveal |
More accurate client-provided IP |
snitcher_radar |
IP → company |
rb2b |
IP → company (live in prod since 2026-03; rb2b_enrichment.py) |
enrich_so |
IP → company (final fallback) |
The short-circuit is per run, not per visitor lifetime. A returning visitor (or a different visitor on the same company) can be matched by a different source on a later run, so one account legitimately accumulates claims from several providers over time.
Account evidence & canonical selection¶
Beyond writing the latest result to visitors.enrichment_data, every account
claim is also recorded as evidence — one row per (visitor_id, provider) in
visitor_account_identifications. A selector then ranks all of a visitor's
evidence by provider priority and marks one canonical, updating
visitors.account_id.
first_party_email(10) → hubspot_assoc(20) → hubspot(30) → vector(40)
→ snitcher(50) → browser_reveal(60) → rb2b(70) → enrich_so(80) (lower wins)
- Write + reselect:
packages/ixdata/ixdata/clients/account_identity.py,reselect_visitor_account_identity. accounts.enrichment_datais account-scoped (shared by every visitor on the account). The seed backfill inherits it into a visitor's evidence only when the visitor has no enrichment of its own, so a shared provider is never echoed onto visitors a different provider resolved.
Person identity (who, not just which company)¶
A separate pipeline identifies the individual, writing one row per
(visitor_id, provider) to visitor_identifications:
| Provider | Status |
|---|---|
vector |
In production (name/email via the Vector webhook) |
rb2b_person |
Person waterfall in enrichment/person_identity.py (IP→HEM→business profile, one provider) |
When a person provider identifies someone at a company, that company is
folded into the account evidence (_fold_into_account_identity in
packages/ixdata/ixdata/clients/person_identity.py) and the canonical selector
re-runs — so a person provider can also set the visitor's main account.
See Person & Account Identity (Staff) for how account and person evidence are compared and judged for agreement.
Data Flow¶
- Widget loads → Sends impression request
- Feature check → Is
enrich_all_visitorsenabled? - Deduplication → Check Redis cache for existing enrichment
- Background enrichment → Query sources asynchronously
- Storage → Save to
visitorstable in Supabase - Session merge → Data available when conversation starts
Deduplication¶
To avoid redundant API calls, enrichments are cached:
async def is_already_enriched(person_id: str | None) -> bool:
cached = await get_cached_enrichment(person_id)
return cached is not None and cached.enrichment_status == "completed"
Cache key: enrichment:{person_id}
Enriched Data¶
Stored in the visitors table:
| Field | Description |
|---|---|
person_id |
PostHog distinct_id |
site_domain |
Site where visitor was seen |
email |
If captured later |
enrichment_data |
JSON with company info |
enrichment_data structure:
{
"company_name": "Acme Corp",
"domain": "acme.com",
"sector": "Technology",
"sub_sector": "SaaS",
"company_description": "..."
}
Using Enriched Data¶
When a conversation starts, enriched data is automatically loaded:
# In chatbot._prepare_query_state
existing_visitor_data = await storage.get_visitor_profile_data(site_domain, person_id)
if existing_visitor_data:
# Merge into visitor_profile
deserialized.visitor_profile = current_profile.model_copy(update=profile_updates)
This enables: - Personalized greetings ("Welcome back!") - Sector-specific content - Skip qualification questions
Debugging¶
Backend logs use the [Impression Enricher] and [Visitor Impression] prefixes:
[Visitor Impression] site=example.com, session=sess_abc..., person=yes, ip=yes
[Impression Enricher] Feature enabled for example.com
[Impression Enricher] Starting enrichment pipeline
[Impression Enricher] Enrichment completed: company=Acme Corp
Privacy Considerations¶
- IP addresses are hashed before storage
- Enrichment data is scoped to the site
- Visitors can be excluded via consent management
Testing¶
poetry run pytest packages/ixchat/tests/test_visitor_profiling.py -v
poetry run pytest apps/api/search/tests/test_visitor_impression.py -v
Related¶
- Returning Visitor Recognition - How enriched data persists
- In-Chat Booking Email - Email capture flow