Skip to content

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

sequenceDiagram participant Widget participant API as Backend API participant Enricher as Impression Enricher participant Sources as Enrichment Sources participant DB as Supabase Widget->>API: POST /api/visitor/impression API->>API: Check feature flag API-->>Widget: {status: "accepted"} Note over API: Background task starts API->>Enricher: enrich_visitor_on_impression() Enricher->>Enricher: Check Redis cache (dedup) Enricher->>Sources: Query enrichment sources Sources-->>Enricher: Company data Enricher->>DB: Store in visitors table

Feature Flag

Enrichment is controlled per-site via enrich_all_visitors:

{
  "qualification": {
    "enrich_all_visitors": true
  }
}

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).

redis_cache → supabase_lookup → browser_reveal → snitcher_radar → rb2b → enrich_so
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_data is 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

  1. Widget loads → Sends impression request
  2. Feature check → Is enrich_all_visitors enabled?
  3. Deduplication → Check Redis cache for existing enrichment
  4. Background enrichment → Query sources asynchronously
  5. Storage → Save to visitors table in Supabase
  6. 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