Score your visitors' purchase intent without a heavy CRM
You want to know which of your visitors is really about to buy — without installing a $200/month CRM and spending three days wiring up workflows. Good news: a simple first-party pixel (a JS script you drop on your site) is enough to capture the signals that matter. The rest is just weighted arithmetic you run in SQL or in a cron job.
The signals that are worth something (and the ones you can throw away)
The classic mistake: tracking 40 events and drowning the signal. In reality, purchase intent shows up in a handful of behaviors. Here are the ones that actually correlate with a conversion, in decreasing order of weight.
- Pricing page visit: signal #1. Someone who opens `/pricing` already pictures themselves as a customer. Weight it heavily.
- Multi-session return: 3 sessions over 7 days = curiosity turning into need. A single session, even a long one, is worth less than three short ones spread out.
- Active time (not time on page): measure engaged time (tab visible + scroll/click), not the tab left open during lunch.
- Scroll depth on key pages: 80% scroll on a product page or a customer case > 100% scroll on the blog.
- 'Bottom-funnel' pages viewed: integration docs, security page, comparison vs. competitor, billing FAQ. These are a buyer's questions, not a curious person's.
- Fake signals to ignore: a single homepage pageview, referral traffic from an aggregator, bots (filter by user-agent and by sessions <2s).
The first-party pixel: what you drop in, what you send
First-party = your own endpoint, your own cookie. No dependency on a third-party SaaS, no blocking by third-party adblockers, and you stay in control of the data (GDPR-friendly if you stay in anonymous intent + consent). Concretely, a lightweight script that sends a heartbeat when the tab is visible, and an event on each key page.
// pixel.js — ~30 lignes, posé sur toutes tes pages
const vid = localStorage.getItem('vid') || crypto.randomUUID();
localStorage.setItem('vid', vid);
function send(type, meta = {}) {
navigator.sendBeacon('/collect', JSON.stringify({
vid, type, path: location.pathname, ts: Date.now(), meta
}));
}
send('pageview');
// temps ACTIF : on n'incrémente que si l'onglet est visible
let active = 0;
setInterval(() => {
if (document.visibilityState === 'visible') {
active += 5;
if (active % 30 === 0) send('heartbeat', { active });
}
}, 5000);
// profondeur de scroll, envoyée une seule fois par palier
let maxScroll = 0;
window.addEventListener('scroll', () => {
const d = Math.round((scrollY + innerHeight) / document.body.scrollHeight * 100);
if (d >= maxScroll + 25) { maxScroll = d; send('scroll', { depth: d }); }
}, { passive: true });
On the server side, `/collect` does a raw `INSERT` into an `events(vid, type, path, ts, meta_jsonb)` table. That's it. You compute nothing on the fly: you store raw events and you score in batch. That saves you double-counting bugs and lets you re-score the whole history whenever you change the weighting.
The scoring formula: weight, cap, decay
A visitor's score = sum of points per signal, capped per signal (otherwise a single scroll maniac skews everything) and decayed over time (a pricing visit from 20 days ago is no longer worth a visit from yesterday). Here's a starting grid — then calibrate it on your real converters.
- `/pricing` visit: +25 per visit, cap 50.
- Bottom-funnel page (docs, security, comparison): +15 each, cap 45.
- Distinct session (>30 min apart): +10, cap 40 (= 4 sessions).
- Each 2-min block of active time: +5, cap 30.
- Scroll ≥75% on a product/case page: +8, cap 16.
- Time decay: multiply each event's score by `0.9 ^ (days_elapsed)`. A 7-day-old event weighs ~48% of its initial value.
-- score par visiteur, recalculé chaque heure en cron
WITH scored AS (
SELECT vid,
LEAST(SUM(CASE WHEN type='pageview' AND path='/pricing' THEN 25 END), 50) AS pricing,
LEAST(SUM(CASE WHEN path = ANY(ARRAY['/docs','/security','/vs']) THEN 15 END), 45) AS funnel,
LEAST(COUNT(DISTINCT date_trunc('hour', to_timestamp(ts/1000))) * 10, 40) AS sessions
FROM events
WHERE to_timestamp(ts/1000) > now() - interval '30 days'
GROUP BY vid
)
SELECT vid, COALESCE(pricing,0)+COALESCE(funnel,0)+COALESCE(sessions,0) AS score
FROM scored ORDER BY score DESC;
The 'hot lead' threshold and the right moment to follow up
An absolute threshold ("80 = hot") ages badly. Prefer a relative threshold: the top 10% of scores over the rolling month. It auto-calibrates when your traffic changes. Concretely, compute the 90th percentile of active scores and treat anything above it as a hot lead to handle within 24h.
- Too early: following up on the first pricing visit = you spook someone who was just benchmarking. Wait for a second strong signal (a return OR a bottom-funnel page).
- Ideal window: score crosses the 90th percentile AND last event < 48h. Intent is ripe and your brand is fresh in their memory.
- Too late: high score but last event > 10 days = train missed. Follow up anyway, but with a 'we shipped X since' angle, not 'ready to buy?'.
- Channel: hot score + known email (newsletter, trial) = personal email within 24h. Anonymous hot score = light retargeting or an in-app banner, no direct follow-up possible.
Start minimal: drop the pixel this week, store the raw events, run the scoring query read-only and watch it run for 10 days without following up on anything. You'll quickly see whether your scores match the reality of your signings. Only then do you wire up the Slack alert and tune the weights on your real converters. A useful intent scoring fits in one events table, one SQL query and a cron — the bloated CRM, save that for when you have a sales team to feed.
The newsletter
By subscribing you agree to receive the Zylior newsletter. One-click unsubscribe in every email.