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.