How to Stream PDF Reader Events into Your Own Database
Dashboards answer the questions their designers anticipated. Your questions live in joins — reading behavior against CRM stages, campaign spend, support tickets — and joins need the signal in your database, not someone else's UI.
This build is the plumbing: one automation that fires on every engagement moment, and a small Go receiver that lands each webhook in Postgres — attributed, session-enriched, and safe to redeliver.
One automation, every moment
- Go to Documents → Automations → New Automation.
- Under “When this happens…” select every event you want warehoused — opens, page reads, downloads, prints, form submits.
- Leave “Only if…” empty (the warehouse filters later, in SQL) and point “Then do this…” at your receiver's Webhook URL.
Know the delivery model before designing the schema: each selected event fires once per session per event type, carrying the recipient and the session's running aggregates. That makes this a session-grain lifecycle stream — ideal for warehousing — while page-by-page telemetry stays queryable on demand via the sessions API.
The receiver: one Go file, one table
It bootstraps its own schema, and the UNIQUE
(session_id, event) constraint plus ON CONFLICT
DO NOTHING make webhook redelivery a non-event:
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
_ "github.com/lib/pq"
)
const schema = `
CREATE TABLE IF NOT EXISTS reader_events (
id BIGSERIAL PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
occurred_at TIMESTAMPTZ NOT NULL,
doc_id TEXT NOT NULL,
doc_name TEXT NOT NULL,
event TEXT NOT NULL,
session_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
recipient TEXT,
page INT,
duration_ms BIGINT,
completion NUMERIC,
raw JSONB NOT NULL,
UNIQUE (session_id, event)
);`
type hook struct {
Document struct {
DocID string `json:"doc_id"`
Name string `json:"name"`
} `json:"document"`
Trigger struct {
MatchedEvent string `json:"matched_event"`
} `json:"trigger"`
Event struct {
SessionID string `json:"session_id"`
ViewerID string `json:"viewer_id"`
Recipient *struct{ Name, Email string } `json:"recipient"`
Data struct {
Page *int `json:"page"`
DurationMs *int64 `json:"duration_ms"`
} `json:"data"`
} `json:"event"`
Session struct {
CompletionRate float64 `json:"completion_rate"`
} `json:"session"`
Timestamp string `json:"timestamp"`
}
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
if _, err := db.Exec(schema); err != nil {
log.Fatal(err)
}
http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var h hook
if err := json.Unmarshal(body, &h); err != nil {
http.Error(w, "bad payload", 400)
return
}
recipient := ""
if h.Event.Recipient != nil {
recipient = h.Event.Recipient.Email
}
// ON CONFLICT DO NOTHING makes redelivery safe — same session + event is one row
_, err := db.Exec(`INSERT INTO reader_events
(occurred_at, doc_id, doc_name, event, session_id, viewer_id, recipient, page, duration_ms, completion, raw)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (session_id, event) DO NOTHING`,
h.Timestamp, h.Document.DocID, h.Document.Name, h.Trigger.MatchedEvent,
h.Event.SessionID, h.Event.ViewerID, recipient,
h.Event.Data.Page, h.Event.Data.DurationMs, h.Session.CompletionRate, string(body))
if err != nil {
log.Println("insert:", err)
http.Error(w, "db error", 500)
return
}
fmt.Fprintln(w, "ok")
})
log.Println("listening on :8090")
log.Fatal(http.ListenAndServe(":8090", nil))
}
The raw JSONB column keeps the full payload
— when you want a field you didn't extract, it's already there.
Watch a session become rows
A real reading visit — opened, three pages read, downloaded — lands as exactly three rows:
warehouse=# SELECT event, doc_name, recipient, page, duration_ms, completion
FROM reader_events ORDER BY id;
event | doc_name | recipient | page | duration_ms | completion
-------------------+---------------------------------+---------------------+------+-------------+------------
document:loaded | 2026 Client Reporting Benchmark | elena@novak.example | | | 37.5
page:read | 2026 Client Reporting Benchmark | elena@novak.example | 1 | 26000 | 37.5
document:download | 2026 Client Reporting Benchmark | elena@novak.example | | | 37.5
And the redelivery test: re-POST the same page:read
payload, get a 200, and the count stays at three. Idempotency isn't a footnote in
webhook systems — it's the difference between a warehouse and a guess.
Now ask your own questions
The payoff is every query the dashboard never imagined:
-- who downloads without reading? (compliance smell)
SELECT recipient
FROM reader_events
GROUP BY recipient, session_id
HAVING bool_or(event = 'document:download')
AND NOT bool_or(event = 'page:read');
Join recipient against your CRM and reading
behavior becomes a column on every deal.
Where to go from here
The stream is running — these builds sit naturally on top.
Related tutorials
Get Notified Only for PDF Engagement That Matters
The ping-on-every-open automation gets muted within a week. The Only-if box fixes it: stack conditions like total reading time and completion rate, and a 15-second skim stays silent while the cover-to-cover read sends exactly one notification — proven live with two readers.
How to Create HubSpot Tasks from PDF Engagement
Your CRM should know when a prospect actually reads the quote. A Page Read automation with a completion-rate condition fires only on real reads, a Zap matches the reader to their HubSpot contact by email, and a follow-up task — with the session numbers in its notes — files itself. No code.
How to Log Every PDF View in a Google Sheet
Build a living view log without writing code: a Document Opened automation posts every view to a Zapier catch hook, and each open becomes a spreadsheet row with timestamp, document and reader. Filters, pivots and a COUNTIF later, your sheet is a mini engagement dashboard.