Logo
Apdf tutorials August 2026 5 min read

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.

What you'll build
The message your channel gets every morning — this exact output, from a real run: ☕ Yesterday's reading — Sandra read the July update 100% · 3m 51s · Ravi read the benchmark 63%
Everything in this tutorial works on the free plan
1

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.

2

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)
}
Tip: 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.
3

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.

Ready to see who reads?