Lead Scoring Integration
Bridges the visitor tracking system and lead capture system so every lead is enriched with an engagement score, helping sales teams prioritize follow-ups.
How It Works
Visitor browses site → tracker.js records behavior → engagement data in D1
Visitor submits form → form.js reads visitor_id from localStorage
Lead API receives form → looks up session by visitor_id (or IP fallback)
Lead stored in D1 + sent to webhook → enriched with scoring fieldsData Flow
- tracker.js stores
visitor_idin localStorage as_pg_vid - form.js reads
_pg_vidand injects it as hidden field__visitor_id - lead.ts receives the form POST with
__visitor_id lookupVisitorScore()queries:- Primary:
sessions WHERE visitor_id = ?(most recent) - Fallback:
sessions WHERE ip = ?only whenvisitor_idwas provided but no session was found (e.g. session expired). If the request has no__visitor_id(API, Postman, server-to-server), no IP fallback is used — the lead is stored with score 0 and label "Cold".
- Primary:
- Fetches fingerprint for return visit count and VPN flag
- Calculates score inline and enriches the lead payload
- Webhook to Make.com carries full scoring data
- D1 Leads table stores scoring columns
Scoring Model (Real Estate Optimized)
| Factor | Points | Logic |
|---|---|---|
| Duration | 0-35 | 1 pt per 10 seconds, cap at ~6 min |
| Scroll Depth | 0-30 | 1 pt per 3.33% depth, full scroll = 30 |
| Clicks | 0-20 | 1 pt per click, cap at 20 |
| Return Visits | 0-15 | 5 pts per return session, cap at 3 returns |
| Total | 0-100 |
Why These Factors for Real Estate
- Duration: A buyer spending 5-10 min reading floor plans and amenities shows strong intent
- Scroll Depth: Reaching the pricing/contact section at the bottom = high intent
- Clicks: Gallery photos, floor plan tabs, brochure downloads = active interest
- Return Visits: Coming back to the same property page = serious buyer
Engagement Labels
| Score | Label | Recommended Action |
|---|---|---|
| 0-20 | Cold | Bounced or bot — low priority |
| 21-45 | Warm | Browsed briefly — follow up within 24h |
| 46-70 | Hot | Read the page, looked at details — call within 2h |
| 71-100 | Very Hot | Deep engagement + return visits — call immediately |
Webhook Payload (New Fields)
{
"visitor_id": "abc123...",
"session_id": "sess-xyz...",
"engagement_score": 72,
"engagement_label": "Very Hot",
"pages_visited": 3,
"session_duration_seconds": 340,
"return_visits": 2,
"vpn_suspected": 0
}D1 Schema Changes (Migration 005)
ALTER TABLE Leads ADD COLUMN VisitorId TEXT;
ALTER TABLE Leads ADD COLUMN SessionId TEXT;
ALTER TABLE Leads ADD COLUMN EngagementScore REAL DEFAULT 0;
ALTER TABLE Leads ADD COLUMN EngagementLabel TEXT DEFAULT 'Cold';
ALTER TABLE Leads ADD COLUMN PagesVisited INTEGER DEFAULT 0;
ALTER TABLE Leads ADD COLUMN SessionDuration INTEGER DEFAULT 0;
ALTER TABLE Leads ADD COLUMN ReturnVisits INTEGER DEFAULT 0;
ALTER TABLE Leads ADD COLUMN VpnSuspected INTEGER DEFAULT 0;Graceful Degradation
- If tracker.js hasn't loaded → no
_pg_vidin localStorage →__visitor_idnot sent → no IP fallback → score 0, "Cold" - If
__visitor_idwas sent but has no matching session → falls back to IP lookup (same visitor, different device or expired session) - If the lead is submitted via API/Postman (no
__visitor_id) → no IP fallback, so we never attach another session's engagement → score 0, "Cold" - If no session found at all → lead saved with score 0, label "Cold"
- Scoring never blocks lead submission — errors caught and logged
Website vs API Leads (scoring by channel)
Leads can come from the website (tracked form with __visitor_id) or from internal APIs (Facebook, WhatsApp, JustLead, etc.) where there is no browser session.
| Channel | Engagement score | Suspicion score | How to distinguish |
|---|---|---|---|
| Website | 0–100 from session (duration, scroll, clicks, returns) | From honeypot, time-to-submit, VPN, duplicate rate, etc. | lead_source = "WebSite" (or no SOURCE in body). |
| API | Not available — stored as 0, label "Cold" | Still computed from payload: no_tracking_session (+15), duplicate count, email domain, honeypot/timeToSubmit if API sends them. | lead_source = value sent in SOURCE (e.g. "Facebook", "WhatsApp", "JustLead"). |
Recommendation
API submissions
SendSOURCEin the request body (e.g.SOURCE: "Facebook","WhatsApp","JustLead"). The worker stores it aslead_sourceso you can filter and report by channel.Engagement in UI/reports
For leads wherelead_sourceis not"WebSite"(or where there was no session), treat engagement as not applicable: show "—" or "N/A" instead of "Cold", so it’s clear we had no behavioral data rather than a low score.Suspicion for API
Suspicion remains meaningful: we still addno_tracking_session, use duplicate count, email domain, and (if the API sends them) honeypot and time-to-submit. Use it to flag risky API leads.No session = no engagement
We do not infer engagement for API leads (e.g. no "medium" default), so analytics and moderation stay consistent: engagement is only from website sessions.