The single highest-leverage instrumentation you can put on a sales, fundraising, or deal-flow process is a real-time Slack alert when an important person opens an important document. The pattern: a webhook fires when a view ends, your handler verifies the signature, filters to high-signal events, and posts a formatted message to a channel the deal team is watching. The deal team learns about engagement in seconds, not at the next Monday status meeting.
This article is the complete implementation. It works against any modern webhook-emitting VDR; the worked example uses the Papermark API. Implementation languages shown: TypeScript (Next.js App Router) and Python (FastAPI). The core HMAC-SHA256 signature verification is identical across both.
The endpoint contract
Papermark webhooks fire POST requests with a JSON body and these headers:
POST /api/papermark-webhook HTTP/1.1
Content-Type: application/json
Content-Length: 1247
X-Papermark-Signature: t=1716742330,v1=4d2c7a3e9b8c6f2a1e8d7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a2918
X-Papermark-Event-Type: view.completed
X-Papermark-Delivery-Id: dlv_01HXY7P3K2NQR4
X-Papermark-Timestamp: 1716742330
X-Papermark-Webhook-Version: 2026-01-01
The signature v1 is an HMAC-SHA256 of <timestamp>.<body>, keyed with your webhook secret. You verify it server-side. Replay protection lives in the t timestamp. Reject events older than 5 minutes (Papermark's recommended tolerance; Stripe uses the same default).
Event types you'll typically subscribe to:
view.completed: fires when a visitor's session ends. The most common alert trigger.view.started: fires immediately when a visitor opens a link. Useful for "is this visitor active right now?" workflows but noisier.link.created: fires when a link is minted. Useful for audit and "log every link to a sheet" workflows.link.revoked: fires when a link is deleted.document.uploaded: fires when a document is added to a dataroom.dataroom.archived: fires when a dataroom is archived.download.attempted: fires on a blocked or completed download attempt.
Most teams subscribe to view.completed and download.attempted for engagement signal, plus link.created for audit.
Step 1: get a webhook secret
In your Papermark dashboard webhook settings (app.papermark.com/settings/webhooks), create a new webhook pointing to your handler URL, choose the events you care about, and copy the signing secret. Store it as PAPERMARK_WEBHOOK_SECRET in your environment. Never in code, never in a config file checked into git.
The signing secret looks like whsec_… and is 64 hex characters. Treat it as a high-privilege secret: rotate every 90 days, use different secrets for dev/staging/prod environments, never log it.
Step 2: the TypeScript handler
A complete Next.js App Router handler with signature verification, replay protection, and Slack posting:
// app/api/papermark-webhook/route.ts
import crypto from "node:crypto";
import { headers } from "next/headers";
export const runtime = "nodejs"; // not edge — node crypto needed
const SECRET = process.env.PAPERMARK_WEBHOOK_SECRET!;
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL!;
const TOLERANCE_SECONDS = 300; // 5 minutes — match Papermark's recommendation
function verify(body: string, header: string | null): boolean {
if (!header) return false;
// Parse "t=...,v1=..." format
const parts = Object.fromEntries(
header.split(",").map((p) => {
const eq = p.indexOf("=");
return [p.slice(0, eq), p.slice(eq + 1)] as [string, string];
}),
);
if (!parts.t || !parts.v1) return false;
// Replay protection
const age = Math.floor(Date.now() / 1000) - parseInt(parts.t, 10);
if (Math.abs(age) > TOLERANCE_SECONDS) return false;
// HMAC compute
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.${body}`)
.digest("hex");
// Constant-time compare to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex"),
);
} catch {
return false;
}
}
export async function POST(req: Request) {
const body = await req.text();
const sig = headers().get("X-Papermark-Signature");
if (!verify(body, sig)) {
console.warn("rejected webhook with invalid signature");
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(body);
// Idempotency — skip duplicates
const deliveryId = headers().get("X-Papermark-Delivery-Id");
if (deliveryId && (await alreadyProcessed(deliveryId))) {
return new Response("ok (already processed)", { status: 200 });
}
if (deliveryId) await markProcessed(deliveryId);
if (event.type === "view.completed") {
// Don't block the response — Papermark expects sub-second ACK
void postToSlack(event.data);
}
return new Response("ok", { status: 200 });
}
async function postToSlack(view: ViewData) {
// Filter to interesting events only
if (!isHighSignal(view)) return;
const minutes = Math.round(view.duration_seconds / 60);
const pages = view.pages.length;
const visitor = view.visitor.email ?? "anonymous";
const text = `*${visitor}* viewed *${view.document_name}* — ${minutes}m across ${pages} pages`;
await fetch(SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
blocks: [
{
type: "section",
text: { type: "mrkdwn", text },
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text:
`Link: \`${view.link_id}\` · ` +
`Country: ${view.visitor.country ?? "?"} · ` +
`Exit page: ${view.exit_page}/${view.pages.length} · ` +
`<https://app.papermark.com/views/${view.id}|Open in dashboard>`,
},
],
},
],
}),
});
}
function isHighSignal(view: ViewData): boolean {
// Long sessions only — skip drive-by views
if (view.duration_seconds < 120) return false;
// Multiple pages — proves they actually read
if (view.pages.length < 3) return false;
// Skip anonymous (email gate not yet filled)
if (!view.visitor.email) return false;
// Skip internal users — adjust to your domain
if (view.visitor.email.endsWith("@yourcompany.com")) return false;
return true;
}
// Implement these against your durable storage (Redis, Postgres, etc.)
async function alreadyProcessed(id: string): Promise<boolean> { /* ... */ return false; }
async function markProcessed(id: string): Promise<void> { /* ... */ }
type ViewData = {
id: string;
link_id: string;
document_name: string;
visitor: { email?: string; country?: string };
duration_seconds: number;
pages: { number: number; duration_seconds: number }[];
exit_page: number;
};
Step 3: the Python equivalent
For FastAPI or similar:
import hmac, hashlib, time, os, json
from fastapi import FastAPI, Request, HTTPException
import httpx
app = FastAPI()
SECRET = os.environ["PAPERMARK_WEBHOOK_SECRET"]
SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"]
TOLERANCE = 300
def verify(body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(p.split("=", 1) for p in header.split(","))
if "t" not in parts or "v1" not in parts:
return False
if abs(time.time() - int(parts["t"])) > TOLERANCE:
return False
expected = hmac.new(
SECRET.encode(),
f"{parts['t']}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
@app.post("/papermark-webhook")
async def handle(req: Request):
body = await req.body()
sig = req.headers.get("x-papermark-signature")
if not verify(body, sig):
raise HTTPException(401, "invalid signature")
event = json.loads(body)
if event["type"] == "view.completed":
await post_to_slack(event["data"])
return {"ok": True}
async def post_to_slack(view):
if view["duration_seconds"] < 120:
return
if not view.get("visitor", {}).get("email"):
return
text = (
f"*{view['visitor']['email']}* viewed *{view['document_name']}* — "
f"{view['duration_seconds'] // 60}m across {len(view['pages'])} pages"
)
async with httpx.AsyncClient() as client:
await client.post(SLACK_WEBHOOK, json={"text": text})
Step 4: register the webhook
In the Papermark dashboard webhook settings, point the webhook URL at https://yourapp.com/api/papermark-webhook and select the event types you want. Send a test event from the dashboard. You should see it land in Slack within 2-3 seconds.
Filtering for signal
The raw firehose of view events is noisy. A typical fundraising dataroom gets dozens of views per day, most of them brief check-ins. A typical M&A dataroom with 15 bidders gets 100-300 views over the diligence period. Without filtering, your channel becomes useless background noise the team learns to ignore.
Filter aggressively. Useful filter dimensions:
- Minimum duration. Sessions under 60-120 seconds are usually noise. Auto-preview-fetch, accidental tap, or someone checking that the link works. Exclude them.
- Minimum pages viewed. Sub-3-page sessions don't prove engagement.
- Verified email present. Anonymous sessions (email gate not yet filled) are too early to act on.
- Internal domain exclusion. Skip views by people at your own company.
- Time-of-day awareness. Optional. Some teams want different alert routing for off-hours vs business-hours engagement.
- Document priority. A view of the deck might be more interesting than a view of the boilerplate NDA.
Combine these into a scoring function:
function signalScore(view: ViewData, recipient: KnownRecipient): number {
let score = 0;
if (view.duration_seconds >= 600) score += 3; // 10+ min
else if (view.duration_seconds >= 300) score += 2; // 5+ min
else if (view.duration_seconds >= 120) score += 1; // 2+ min
if (view.pages.length >= view.total_pages * 0.8) score += 2; // mostly through
if (recipient.priority === "tier1") score += 2; // target VC
if (view.exit_page === view.total_pages) score += 1; // finished
if (view.downloads_attempted > 0) score += 1; // engaged
return score;
}
Route based on score:
- Score ≥ 5,
#fundraising-hotchannel +@herenotification. - Score 3-4,
#fundraising-warmchannel, no notification. - Score 1-2: silent log channel for retrospective analysis.
- Score 0: discard.
Adding interactive actions
Slack supports interactive messages. Add "Mark as hot lead" and "Revoke access" buttons directly on the alert:
blocks: [
{ type: "section", text: { type: "mrkdwn", text } },
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Mark hot lead" },
action_id: "mark_hot",
value: view.visitor.email,
style: "primary",
},
{
type: "button",
text: { type: "plain_text", text: "Schedule follow-up" },
action_id: "schedule_followup",
value: view.visitor.email,
},
{
type: "button",
text: { type: "plain_text", text: "Revoke access" },
action_id: "revoke",
value: view.link_id,
style: "danger",
confirm: {
title: { type: "plain_text", text: "Revoke this link?" },
text: { type: "mrkdwn", text: "The recipient will immediately lose access." },
confirm: { type: "plain_text", text: "Yes, revoke" },
deny: { type: "plain_text", text: "Cancel" },
},
},
],
},
],
Wire the action handler to your CRM, calendar, and the Papermark API respectively. The "mark hot lead" button writes to your CRM; "schedule follow-up" creates a calendar event; "revoke access" calls the Papermark links revoke endpoint.
Common pitfalls
Eight things that bite teams implementing this for the first time:
- Verify the signature, every time, in constant time. Webhook URLs are publicly addressable. Anyone who finds yours can send fake events if you don't verify. Use
crypto.timingSafeEqual(Node) orhmac.compare_digest(Python). Never==. - Return 2xx fast. Papermark retries on non-2xx with exponential backoff (up to 5 attempts). If you do heavy work in the handler, queue it (Inngest, BullMQ, SQS, Sidekiq) and return 200 immediately. Aim for sub-200ms response.
- Idempotency via delivery ID. Track
X-Papermark-Delivery-Idin durable storage and skip duplicates. Retries can produce double-processing without this. - Don't log the secret. Yes, this happens. Stripe-style secret-scanning bots find webhook secrets in GitHub within minutes of commit. Use environment variables only.
- Test the unhappy paths. What happens when Slack is down? When your idempotency store is unreachable? When the event payload has an unexpected field? Webhook handlers fail in production in ways that mock tests don't catch.
- Don't trust client-attributed fields.
visitor.countryis derived from IP geolocation.visitor.user_agentis self-reported. Treat them as informative but not authoritative. - Beware clock skew. The
Math.abs(age)check above handles small clock differences in both directions. Without it, a webhook with a slightly future timestamp (server clock drift) would be rejected. - Plan for the webhook secret rotation flow. When you rotate the secret in the Papermark dashboard, your handler needs to accept the old secret for a grace period. Either run two secrets in parallel briefly, or deploy the new secret to your handler before rotating in the dashboard.
See also
More in Cookbook
One-script provisioning: create, populate, and share a virtual data room in 60 seconds
A copy-pasteable end-to-end script that creates a data room, uploads a folder of files, mints a tracked link, and prints the URL: bash, Node, and Python variants. Implementation against the Papermark API.
Per-recipient share links: one dataroom, N watermarked URLs
Generate one share link per recipient with per-recipient watermarks, per-recipient policy, and CRM write-back: the single most useful pattern in programmable data rooms. Worked example uses the Papermark API.