Logo
Apdf tutorials September 2026 4 min read

How to Track Who Read the Documents They Were Required to Read

“Required reading” usually means an email attachment and a prayer. When the auditor — or the incident report — asks who actually read the safety policy, the honest answer is a shrug: delivered isn't opened, opened isn't read, and a reply saying “done!” isn't evidence of either.

Tracked links fix the reading question; a one-checkbox acknowledgement form on the policy's last page fixes the signature question. Together they produce the thing compliance actually wants: a per-person roster of read and confirmed.

What you'll build
A safety policy sent to four employees — and a roster that survives an audit: Ines: 100% · acknowledged ✓ · Jonas: 80% · NOT acknowledged · Omar: never opened
Everything in this tutorial works on the free plan
1

Put the signature inside the document

The policy PDF gets one extra page: an acknowledgement form with a checkbox and a name field. The field names become the payload keys, so keep them meaningful:

policy_acknowledged   (checkbox) — "I have read and understood the Workplace Safety Policy 2026 (v3.1)."
full_name             (text)

Upload it privately and mint one link per employee — the link is what turns anonymous page views into Jonas read pages 1–4:

curl -X POST https://apdf.io/api/docs/5ae87-45d34-6bb06/links \
  -H "Authorization: Bearer $API_TOKEN" -H "Accept: application/json" \
  --data-urlencode "name=Ines Kowalczyk" \
  --data-urlencode "email=ines@norda.example"
2

The acknowledgement that holds up

Employees read the policy in the browser and confirm on the last page — no printout, no separate survey tool. The submission arrives attributed and timestamped:

GET /api/docs/5ae87-45d34-6bb06/form-submissions

{
    "doc_id": "5ae87-45d34-6bb06",
    "creator_id": "NAYV1cyAyyuo",
    "recipient": {
        "name": "Ines Kowalczyk",
        "email": "ines@norda.example"
    },
    "fields": [
        {
            "id": "18R",
            "name": "policy_acknowledged",
            "page": 5,
            "type": "checkbox",
            "value": true,
            "checked": true,
            "exportValue": "Yes"
        },
        {
            "id": "24R",
            "name": "full_name",
            "page": 5,
            "type": "text",
            "value": "Ines Kowalczyk"
        }
    ],
    "created_at": "2026-07-23T09:50:13.000000Z",
    "updated_at": "2026-07-23T09:50:13.000000Z"
}

And crucially, the same visit's reading session sits next to it: Ines viewed all 5 pages before checking the box. An acknowledgement backed by a recorded read is a very different artifact than a checkbox alone.

3

Build the compliance roster

Three endpoints — the link roster, the sessions, the submissions — joined by one script into the answer HR needs:

#!/usr/bin/env bash
# Compliance roster: who read the policy, who acknowledged, who needs a nudge.
API="https://apdf.io/api/docs/$DOC_ID"
H=(-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/json")

LINKS=$(curl -s "${H[@]}" "$API/links?per_page=100");            sleep 1
SESSIONS=$(curl -s "${H[@]}" "$API/analytics/sessions?per_page=100"); sleep 1
ACKS=$(curl -s "${H[@]}" "$API/form-submissions?per_page=100")

jq -n --argjson links "$LINKS" --argjson sessions "$SESSIONS" --argjson acks "$ACKS" -r '
  ($acks.data | map(select(.fields[] | select(.name == "policy_acknowledged" and .checked == true))
    | .recipient.email)) as $acked |
  ($sessions.data | group_by(.recipient.email)
    | map({ (.[0].recipient.email): (map(.completion_pct) | max) }) | add // {}) as $read |
  $links.data[] |
  .email as $e |
  if ($acked | index($e)) then "\(.name): read \($read[$e])% · acknowledged ✓"
  elif $read[$e] then "\(.name): read \($read[$e])% · NOT acknowledged"
  else "\(.name): never opened"
  end
'
$ DOC_ID=5ae87-45d34-6bb06 API_TOKEN=... ./policy-status.sh
Omar Haddad: never opened
Petra Simon: read 40% · NOT acknowledged
Jonas Petri: read 80% · NOT acknowledged
Ines Kowalczyk: read 100% · acknowledged ✓

Each line is its own follow-up: Jonas read the policy but skipped the checkbox (a one-line reminder), Petra stopped at the PPE section (a different conversation), and Omar's link was never clicked — check delivery before escalating.

4

Close the loop automatically

Run the roster script weekly from cron and mail the “NOT acknowledged” lines to team leads — or flip it to push: an automation on the form-submit event checks names off in your HR system the moment each acknowledgement lands. The forms tutorial wires that webhook end to end.

Where to go from here

The same building blocks, pointed at adjacent problems.

Ready to see who reads?