Logo
Apdf tutorials July 2026 5 min read

How to Get Alerted When a Client Re-Opens Your Proposal

The first open of a proposal doesn't mean much — it's a courtesy click, often minutes after your email lands. The signal that matters is the re-open: the client came back. They're comparing numbers, re-reading the scope, or showing it to whoever signs off. That's the moment a call actually lands.

And it's exactly the moment you never see. In this tutorial you'll build an alert that stays silent on the first view and fires the instant a client returns.

What you'll build
A re-open alarm for your proposal: every reading visit fires a webhook, a small Python receiver counts visits per recipient, and from the second visit on your team gets the message that matters: 🔥 Alex Moreau just re-opened “Q3 Services Proposal” (visit #2) — strike while it's hot
Free plan for tracking · automations need Pro (14-day trial, no card)
1

Put the proposal behind a recipient link

Upload the proposal as a tracked document and mint one link per client contact. The link carries their identity, so every visit downstream arrives with a name on it.

curl -X POST https://apdf.io/api/docs \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json" \
  -d file="https://your-server.com/q3-services-proposal.pdf" \
  -d name="Q3 Services Proposal"

curl -X POST https://apdf.io/api/docs/4352c-99928-25aa1/links \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json" \
  -d name="Alex Moreau" \
  -d email="alex@clientco.example"
{
    "data": {
        "token": "CMgHcYVC",
        "name": "Alex Moreau",
        "email": "alex@clientco.example",
        "url": "https://docs.apdf.io/studio/4352c-99928-25aa1?v=CMgHcYVC",
        "is_active": true,
        "last_viewed_at": null,
        "created_at": "2026-07-23T06:54:11.000000Z"
    }
}

Send Alex the url — from here on, every visit is a signal you can catch. Both steps also work in the dashboard.

2

Fire a webhook on every reading visit

The Document Opened trigger fires once per reading session — which makes each webhook exactly one visit. Wire it up:

  1. Go to Automations in the dashboard and click New Automation.
  2. Under “When this happens…” pick Document Opened. Scope it to your proposal or all documents.
  3. Under “Then do this…” paste your Webhook URL (step 4's receiver) and save.
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.
3

Understand what arrives — once per visit

Every visit delivers the same payload shape: the document, the recipient, and the session. Note that the session block is young — this fires the moment they open, before any reading has happened:

{
    "automation": {
        "id": "b2423-a1c37-de039",
        "name": "Proposal opened"
    },
    "document": {
        "doc_id": "4352c-99928-25aa1",
        "name": "Q3 Services Proposal",
        "viewer_url": "https://docs.apdf.io/studio/4352c-99928-25aa1",
        "file_pages": 3
    },
    "trigger": {
        "matched_event": "document:loaded",
        "conditions_evaluated": false
    },
    "event": {
        "session_id": "bQf7sLw3Nc9dGh5j",
        "viewer_id": "aMxV3nKj9qLp2wSe",
        "recipient": {
            "name": "Alex Moreau",
            "email": "alex@clientco.example"
        },
        "data": null
    },
    "session": {
        "total_duration_ms": 0,
        "pages_viewed": 0,
        "completion_rate": 0
    },
    "timestamp": "2026-07-23T06:54:42+00:00"
}

A first open and a re-open look identical on the wire — distinguishing them is your receiver's one job: count visits per document and recipient, and only act from visit two.

4

Alert only on the re-open

A complete receiver in plain Python — standard library only. It keeps a tiny visit counter on disk, stays silent on visit one, and posts to your Slack incoming webhook from visit two on:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.request import Request, urlopen

SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/XXX/YYY/ZZZ"
STATE_FILE = Path("visits.json")


def visit_number(doc_id: str, email: str) -> int:
    """Count this open per document and recipient — 1 on the first visit, 2+ on re-opens."""
    state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}
    key = f"{doc_id}:{email}"
    state[key] = state.get(key, 0) + 1
    STATE_FILE.write_text(json.dumps(state))
    return state[key]


class ReopenAlerter(BaseHTTPRequestHandler):
    def do_POST(self):
        payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))

        doc = payload["document"]
        recipient = payload["event"]["recipient"] or {}
        visits = visit_number(doc["doc_id"], recipient.get("email", "unknown"))

        if visits >= 2:
            text = f"🔥 {recipient.get('name', 'Someone')} just re-opened \"{doc['name']}\" (visit #{visits}) — strike while it's hot: {doc['viewer_url']}"
            urlopen(Request(SLACK_WEBHOOK_URL, data=json.dumps({"text": text}).encode(), headers={"Content-Type": "application/json"}))

        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")


HTTPServer(("0.0.0.0", 4790), ReopenAlerter).serve_forever()
python3 reopen_alert.py

Deploy it anywhere with a public URL and paste that URL into the automation from step 2. Alex's first open stays quiet; the moment he comes back, your channel hears:

🔥 Alex Moreau just re-opened “Q3 Services Proposal” (visit #2) — strike while it's hot
Tip: Want fewer, hotter alerts? Add a condition to the automation — for example a minimum total reading time — so quick bounces never fire the webhook at all.

Where to go from here

The re-open is one play from a bigger book — the same signals power all of them.

Ready to see who reads?