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
Handle Document Webhooks Reliably
Webhook demos end at 200 OK; production starts there. One plain-JDK Java file covers the three duties the delivery contract implies — ack fast (10s timeout, no retries), dedupe durably (state that survives restarts), and reconcile against the execution log, which carries every failed delivery's full payload for safe replay.
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.