Logo
Apdf tutorials August 2026 5 min read

How to Find Where Readers Drop Off Your PDF

“Average completion: 66%” sounds like a verdict on the whole document. It almost never is. Most PDFs don't lose readers evenly — they lose them at one page: the dense methodology spread, the wall-of-text terms section, the pricing table that needed a warm-up it didn't get.

Page-level reading data finds that page. This tutorial pulls it from the sessions API and turns ten reading sessions into a funnel with the cliff marked — so you fix one spread instead of rewriting the whole report.

What you'll build
A per-page funnel for a benchmark report, computed from real reading sessions — and a verdict you can act on: page 5 → 6: readers kept drops from 89% to 38% — the methodology page is the cliff
Everything in this tutorial works on the free plan
1

Track reading, not just opens

Per-page data needs the document behind tracking links — one per recipient, so every session carries a name:

curl -X POST https://apdf.io/api/docs/8432e-b4ebb-74433/links \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json" \
  -d name="Lucia Ferraro" \
  -d email="lucia@ferraro.example"

From then on the viewer records which pages each reader entered and how long they stayed — the raw material for everything below. (New here? The recipient analytics tutorial covers the setup end to end.)

2

Read one session's story first

Before aggregating, look at a single reader. In the dashboard, go to Documents → Analytics, open the document, switch to the Sessions tab and expand a session:

An expanded session in the dashboard: 75% completion, 6 of 8 pages, with an activity timeline showing dwell times per page that end at page 6

This reader tells the whole story in miniature: steady 38–47s per page, 53 seconds on page 5 (the case study — working), 32 seconds on page 6… and gone. The same data comes back over the API from the session detail endpoint:

GET /api/docs/8432e-b4ebb-74433/analytics/sessions/{sessionId}

{
    "metrics": {
        "total_duration_ms": 254500,
        "pages_viewed": 6,
        "total_pages": 8,
        "completion_pct": 75,
        "downloads": 0,
        "prints": 0
    },
    "pages": [
        { "page": 1, "views": 1, "duration_ms": 47000, "viewed": true },
        { "page": 2, "views": 1, "duration_ms": 44000, "viewed": true },
        { "page": 3, "views": 1, "duration_ms": 41000, "viewed": true },
        { "page": 4, "views": 1, "duration_ms": 38000, "viewed": true },
        { "page": 5, "views": 1, "duration_ms": 52500, "viewed": true },
        { "page": 6, "views": 1, "duration_ms": 32000, "viewed": true },
        { "page": 7, "views": 0, "duration_ms": 0, "viewed": false },
        { "page": 8, "views": 0, "duration_ms": 0, "viewed": false }
    ]
}

(The response also carries the session's recipient and full event timeline — the screenshot above is rendered from it.)

3

Build the funnel across all readers

One reader is an anecdote; the cliff shows up when you stack them. This script lists the sessions, pulls each one's per-page data, and prints how many readers each page kept — standard library only:

import json
import os
import time
import urllib.request

BASE = os.environ.get("BASE", "https://apdf.io")
DOC_ID = os.environ["DOC_ID"]
TOKEN = os.environ["API_TOKEN"]

def get(path):
    req = urllib.request.Request(f"{BASE}/api{path}", headers={
        "Authorization": f"Bearer {TOKEN}",
        "Accept": "application/json",
    })
    with urllib.request.urlopen(req) as res:
        return json.load(res)

sessions = get(f"/docs/{DOC_ID}/analytics/sessions?per_page=100")["data"]

reached = {}   # page -> sessions that viewed it
dwell = {}     # page -> total ms across sessions
total_pages = 0

for s in sessions:
    time.sleep(0.4)  # stay under the API's burst rate limit
    detail = get(f"/docs/{DOC_ID}/analytics/sessions/{s['session_id']}")["data"]
    total_pages = detail["metrics"]["total_pages"]
    for p in detail["pages"]:
        if p["viewed"]:
            reached[p["page"]] = reached.get(p["page"], 0) + 1
            dwell[p["page"]] = dwell.get(p["page"], 0) + p["duration_ms"]

n = len(sessions)
print(f"{n} sessions · {total_pages} pages\n")
print(f"{'page':>4}  {'reached':>7}  {'kept':>5}  {'avg dwell':>9}")

prev = n
for page in range(1, total_pages + 1):
    r = reached.get(page, 0)
    kept = f"{r / prev * 100:3.0f}%" if prev else "  —"
    avg = f"{dwell.get(page, 0) / r / 1000:5.0f}s" if r else "    —"
    cliff = "  ← the cliff" if prev and r / prev < 0.5 else ""
    print(f"{page:>4}  {r:>4}/{n:<2}  {kept:>5}  {avg:>9}{cliff}")
    prev = r

Run against the benchmark report's ten sessions:

$ DOC_ID=8432e-b4ebb-74433 API_TOKEN=... python3 dropoff.py
10 sessions · 8 pages

page  reached   kept  avg dwell
   1    10/10   100%        36s
   2    10/10   100%        33s
   3    10/10   100%        31s
   4     9/10    90%        30s
   5     8/10    89%        44s
   6     3/10    38%        33s  ← the cliff
   7     2/10    67%        30s
   8     1/10    50%        29s
4

Fix the page, not the paper

Now the 66% average has an address. Reading the funnel:

  • Page 5 works — highest dwell in the document (44s average). The case study holds attention; don't touch it.
  • Page 6 is the cliff — five of eight remaining readers quit on the methodology spread. Move it to an appendix and let the recommendations follow the case study directly.
  • Pages 7–8 are hostages — they're probably fine; almost nobody survived page 6 to judge them.

Ship the reordered version, send the next batch of links, re-run the script — the funnel is your before/after measurement. That loop is the same signal thinking that times your follow-ups.

Where to go from here

Per-page data answers more questions than “where do they quit?”

Ready to see who reads?