Logo
Apdf tutorials July 2026 4 min read

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.

What you'll build
An engagement-scored lead list — completion and reading time in, tiers out, ghosts included: HOT  100  Elena Petrova · WARM  59  Marcus Chen · GHOST  0  Tom Okafor
Everything in this tutorial works on the free plan
1

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.

2

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
}
3

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.

Ready to see who reads?