Logo
Apdf tutorials September 2026 5 min read

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.

What you'll build
A reading session becoming warehouse rows in real time: document:loaded · page:read · document:download — three rows, all naming elena@novak.example
Automations are a Pro feature (14-day trial, no card)
1

One automation, every moment

  1. Go to Documents → AutomationsNew Automation.
  2. Under “When this happens…” select every event you want warehoused — opens, page reads, downloads, prints, form submits.
  3. 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.

Plan note: Automations are a Pro feature. Every new account can trial Pro for 14 days — no credit card — which covers everything in this tutorial.
2

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.

3

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.

4

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.

Ready to see who reads?