dataroom.dev

Cookbook

Forward data room view events to Slack: real-time engagement alerts via webhook

Wire data room webhooks into a Slack channel: verify signatures, filter to high-signal events, add interactive actions, route by recipient priority. Complete Next.js handler with HMAC verification.

Read full docsRead the full webhook documentation
April 15, 2026·8 min read·By dataroom.dev

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:

  1. view.completed: fires when a visitor's session ends. The most common alert trigger.
  2. view.started: fires immediately when a visitor opens a link. Useful for "is this visitor active right now?" workflows but noisier.
  3. link.created: fires when a link is minted. Useful for audit and "log every link to a sheet" workflows.
  4. link.revoked: fires when a link is deleted.
  5. document.uploaded: fires when a document is added to a dataroom.
  6. dataroom.archived: fires when a dataroom is archived.
  7. 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:

  1. Minimum duration. Sessions under 60-120 seconds are usually noise. Auto-preview-fetch, accidental tap, or someone checking that the link works. Exclude them.
  2. Minimum pages viewed. Sub-3-page sessions don't prove engagement.
  3. Verified email present. Anonymous sessions (email gate not yet filled) are too early to act on.
  4. Internal domain exclusion. Skip views by people at your own company.
  5. Time-of-day awareness. Optional. Some teams want different alert routing for off-hours vs business-hours engagement.
  6. 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:

  1. Score ≥ 5, #fundraising-hot channel + @here notification.
  2. Score 3-4, #fundraising-warm channel, no notification.
  3. Score 1-2: silent log channel for retrospective analysis.
  4. 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:

  1. 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) or hmac.compare_digest (Python). Never ==.
  2. 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.
  3. Idempotency via delivery ID. Track X-Papermark-Delivery-Id in durable storage and skip duplicates. Retries can produce double-processing without this.
  4. 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.
  5. 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.
  6. Don't trust client-attributed fields. visitor.country is derived from IP geolocation. visitor.user_agent is self-reported. Treat them as informative but not authoritative.
  7. 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.
  8. 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