How to Score Your Leads by How They Read Your Whitepaper
Your whitepaper got 200 downloads last month — and your sales team got 200 identical "leads". But a download is a click, not intent. The lead who read all six pages and spent a minute on your vendor-comparison framework is not the same lead as the one who bounced off page 1 — and neither is the one who never opened it at all.
In this tutorial you'll separate them: every lead gets a personal link, every read becomes data, and a small script turns the data into a scored, ranked list.
Give every lead a personal link
Instead of a public download URL, your form handler mints a tracking link per lead the moment they sign up — one call, and the confirmation email carries their personal URL:
curl -X POST https://apdf.io/api/docs/9e7b2-2f1a0-f5d13/links \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json" \
-d name="Elena Petrova" \
-d email="elena@logisticsplus.example"
From here, everything Elena does with the whitepaper is attributed to Elena — and every lead who never opens their link is visible too, precisely because their link exists and their sessions don't.
Know what a reading session tells you
Each lead's sessions come back with the two numbers the score is built from — completion and total reading time:
{
"session_id": "sElenaHot0Read01",
"viewer_id": "vElenaPetrova1xx",
"recipient": {
"name": "Elena Petrova",
"email": "elena@logisticsplus.example"
},
"device_type": "desktop",
"browser": "Chrome",
"os": "Windows",
"referer": null,
"utm_source": null,
"created_at": "2026-07-22 11:24:21.317",
"pages_viewed": 6,
"total_duration_ms": 283000,
"completion_pct": 100,
"prints": 0,
"downloads": 0
}
Score and rank — one script
The script joins the two lists — links (every lead) and sessions (every actual read) — and weighs completion at 60% and reading time (capped at 3 minutes) at 40%. Plain Node, no dependencies:
const API_TOKEN = process.env.APDF_API_TOKEN;
const DOC_ID = process.env.DOC_ID;
const BASE_URL = process.env.APDF_BASE_URL ?? "https://apdf.io";
const get = async (path) => {
const res = await fetch(`${BASE_URL}/api/docs/${DOC_ID}${path}`, {
headers: { Authorization: `Bearer ${API_TOKEN}`, Accept: "application/json" },
});
return (await res.json()).data;
};
const [links, sessions] = await Promise.all([get("/links"), get("/analytics/sessions")]);
// Score each lead: completion (60%), reading time vs 3 minutes (40%)
const scored = links.map((link) => {
const theirs = sessions.filter((s) => s.recipient?.email === link.email);
const completion = Math.max(0, ...theirs.map((s) => s.completion_pct), 0);
const duration = theirs.reduce((sum, s) => sum + s.total_duration_ms, 0);
const score = Math.round(0.6 * completion + 0.4 * Math.min(duration / 180_000, 1) * 100);
const tier = score >= 70 ? "HOT " : score >= 30 ? "WARM" : theirs.length ? "COLD" : "GHOST";
return { name: link.name, email: link.email, score, tier, sessions: theirs.length };
});
scored.sort((a, b) => b.score - a.score);
console.log("Lead score — who deserves the sales call\n" + "=".repeat(50));
for (const lead of scored) {
console.log(`${lead.tier} ${String(lead.score).padStart(3)} ${lead.name} <${lead.email}> · ${lead.sessions} session(s)`);
}
APDF_API_TOKEN=your_token DOC_ID=9e7b2-2f1a0-f5d13 node score_leads.mjs
Lead score — who deserves the sales call
==================================================
HOT 100 Elena Petrova <elena@logisticsplus.example> · 1 session(s)
WARM 59 Marcus Chen <marcus@fulfilio.example> · 1 session(s)
COLD 12 Priya Nair <priya@stockrapid.example> · 1 session(s)
GHOST 0 Tom Okafor <tom@shelfworks.example> · 0 session(s)
Now the tiers route themselves: HOT goes to sales today, WARM into the nurture sequence, COLD gets a different asset next time — and GHOST tells you the follow-up email needs a better subject line, not a better whitepaper.
Where to go from here
Scoring is a batch view — the same signals also work one lead at a time, live.