How to Build a Daily PDF Engagement Digest for Your Sales Team
Reading signals are only worth what someone does with them — and nobody checks a dashboard on the off-chance. Real-time alerts cover the hot moments, but the habit-forming version is simpler: one Slack message with the coffee, listing who engaged with what yesterday.
This is that message, built as a single-file Go program on a cron: it sweeps every tracked document over the analytics API, splits real reads from courtesy opens, and posts the roundup to your team channel.
Know the two endpoints it sweeps
The digest needs nothing exotic: GET /api/docs
lists every tracked document, and each document's sessions endpoint returns its
reading sessions, newest first, each one attributed and measured:
GET /api/docs/{docId}/analytics/sessions
{
"session_id": "nxljgbYN1SSmksy4",
"viewer_id": "yBVpCOPUJkBVS4yK",
"recipient": {
"name": "Ravi Patel",
"email": "ravi@patelgroup.example"
},
"device_type": "desktop",
"browser": "Firefox",
"os": "Windows",
"referer": null,
"utm_source": null,
"created_at": "2026-07-22 17:03:12.000",
"pages_viewed": 5,
"total_duration_ms": 169100,
"completion_pct": 63,
"prints": 0,
"downloads": 0
}
The endpoint has no date parameter — sessions come newest first, so the
digest filters “yesterday” on the client from
created_at.
The whole digest is one Go file
Standard library only — no modules to vendor on the cron box. It paces itself under the API's burst limit and files anything shorter than ten seconds under “opened” rather than pretending it was read:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
var (
base = envOr("BASE", "https://apdf.io")
token = os.Getenv("API_TOKEN")
slack = os.Getenv("SLACK_WEBHOOK_URL")
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func get(path string, out any) error {
req, _ := http.NewRequest("GET", base+"/api"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("%s: HTTP %d", path, res.StatusCode)
}
return json.NewDecoder(res.Body).Decode(out)
}
type doc struct {
DocID string `json:"doc_id"`
Name string `json:"name"`
}
type session struct {
Recipient *struct{ Name, Email string } `json:"recipient"`
CreatedAt string `json:"created_at"`
CompletionPct int `json:"completion_pct"`
TotalDurationMs int `json:"total_duration_ms"`
}
func main() {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
var docs struct{ Data []doc }
if err := get("/docs?per_page=100", &docs); err != nil {
panic(err)
}
var lines []string
for _, d := range docs.Data {
time.Sleep(400 * time.Millisecond) // stay under the API's burst rate limit
var sessions struct{ Data []session }
if err := get("/docs/"+d.DocID+"/analytics/sessions?per_page=100", &sessions); err != nil {
fmt.Fprintln(os.Stderr, "skip:", err)
continue
}
for _, s := range sessions.Data {
if !strings.HasPrefix(s.CreatedAt, yesterday) {
continue
}
who := "Anonymous reader"
if s.Recipient != nil {
who = s.Recipient.Name
}
if s.TotalDurationMs < 10000 {
lines = append(lines, fmt.Sprintf("• *%s* opened *%s*", who, d.Name))
continue
}
mins := s.TotalDurationMs / 60000
secs := s.TotalDurationMs % 60000 / 1000
lines = append(lines, fmt.Sprintf("• *%s* read *%s* — %d%% · %dm %02ds",
who, d.Name, s.CompletionPct, mins, secs))
}
}
if len(lines) == 0 {
fmt.Println("No reading activity yesterday — nothing to send.")
return
}
message := fmt.Sprintf(":coffee: *Yesterday's reading — %s*\n%s", yesterday, strings.Join(lines, "\n"))
fmt.Println(message)
payload, _ := json.Marshal(map[string]string{"text": message})
res, err := http.Post(slack, "application/json", bytes.NewReader(payload))
if err != nil {
panic(err)
}
fmt.Println("slack:", res.Status)
}
SLACK_WEBHOOK_URL is a Slack incoming webhook —
created in about a minute at api.slack.com → Your Apps → Incoming
Webhooks, and it pins the message to one channel. Anything that accepts a JSON
POST works the same way — Teams, Discord, your own bot.
Run it, then let cron own it
A real morning's output, verbatim — note how the ten-second rule sorts the courtesy clicks from the real reads:
$ API_TOKEN=... SLACK_WEBHOOK_URL=... go run digest.go
:coffee: *Yesterday's reading — 2026-07-22*
• *Tom Berger* opened *Homepage Redesign Proposal*
• *Priya Shah* opened *Homepage Redesign Proposal*
• *Dana Kim* opened *Vendor Intake Form*
• *Dana Kim* opened *Vendor Intake Form*
• *Sandra Okafor* read *Investor Update — July 2026* — 100% · 3m 51s
• *Rachel Kim* read *Investor Update — July 2026* — 50% · 0m 20s
• *Ravi Patel* read *2026 Client Reporting Benchmark* — 63% · 2m 49s
slack: 200 OK
Then schedule it for every weekday morning:
0 8 * * 1-5 cd /opt/digest && API_TOKEN=... SLACK_WEBHOOK_URL=... go run digest.go
From here the digest earns its keep on its own: Sandra's 3m 51s deep read is a call-today signal, Dana opening the intake form twice without reading is a nudge, and the names that never appear are a message too.
Where to go from here
The digest is the daily heartbeat — these are the interrupts.
Related tutorials
Freelancers: A Proposal Workflow Without Per-Seat Tools
Proposal software is priced for sales teams; freelancers just need the signal. A one-person workflow on the free plan: links per contact reveal the 12-minute open, the internal forward, and the 21:21 return visit that camped on the price page — plus the three-slot pipeline trick and honest edges.
Track Who Read the Documents They Were Required to Read
When the auditor asks who read the safety policy, 'we emailed it' is a shrug. Per-person links measure the reading, an in-document acknowledgement checkbox collects the signature, and one script joins them into a defensible roster: read 100% and acknowledged, read but unsigned, never opened.
Ask Claude Which Deals Are Hot
The morning question isn't about one document — it's 'across everything, who should I call first?' With the workspace connected over MCP, the agent sweeps every document's sessions, ranks re-opens over deep reads over skims, and answers with evidence: the Arslans came back for the price page.