Webhooks

HTMLvault can POST a signed JSON notification to your server whenever something happens to a link — created, viewed, updated, expired — or when your tracked-view usage crosses a threshold. Register up to five endpoints on the webhook settings screen; each one gets its own signing secret. Webhooks are a Pro and Enterprise feature.

The delivery envelope

Every delivery is a JSON POST with the same envelope. The event-specific fields live under data; everything else is constant across event types.

{
  "id": "0b9c1f6e-2d4a-4c8b-9f3e-7a1d5c2b6e80",
  "event": "link.viewed",
  "timestamp": "2026-06-13T15:04:11.882Z",
  "data": { … }
}
FieldTypeDescription
idstring (UUID)Stable event ID. Identical across every retry of the same delivery — use it to deduplicate.
eventstringThe event type, e.g. link.created.
timestampstring (ISO 8601)When the delivery was enqueued. Verify against this to reject replays (see Signing).
dataobjectEvent-specific payload. Shapes below.

Three headers accompany every request:

FieldTypeDescription
X-HTMLvault-SignatureheaderHex HMAC-SHA256 of the raw request body, signed with your endpoint secret.
X-HTMLvault-EventheaderThe event type (also in the body).
X-HTMLvault-DeliveryheaderA fresh UUID per attempt — changes on every retry. (The stable id is data.id’s sibling, the body id.)

Event catalog

Subscribe to specific events or to * (all) when registering an endpoint. Seven events fire today.

Fires when an authenticated upload creates a link — from the web app, the REST API, or the MCP create_link tool. Anonymous (logged-out) uploads do not fire it.

data

{
  "slug": "k9m2p4q7r1s8t3v6",
  "title": "Q3 Proposal",
  "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "created_at": "2026-06-13T15:04:11.882Z"
}

Fires when a link’s HTML is replaced in place — from the dashboard editor, the REST PATCH, or the MCP update_link tool. The slug and URL are unchanged.

data

{
  "slug": "k9m2p4q7r1s8t3v6",
  "title": "Q3 Proposal",
  "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "updated_at": "2026-06-13T16:20:55.114Z"
}

Fires on each view of a link you own, with the per-view detail captured at view time.recipient_slug/recipient_ref/recipient_label are non-null when the view came through a recipient-tracked URL — see recipient.viewed below for a narrower subscription covering just those. is_unique is true only for a viewer’s first counted visit, and visit_number is their running visit count (1 for a first visit, 2 for their next, and so on). This event fires on every view, repeat visits included; filter on either field if you only want a first-open signal.

data

{
  "slug": "k9m2p4q7r1s8t3v6",
  "view_id": "8f14e45f-ceea-4f7e-9c1b-2d3a4b5c6d7e",
  "is_unique": true,
  "visit_number": 1,
  "country": "United States",
  "city": "Austin",
  "browser": "Chrome",
  "os": "macOS",
  "device_type": "desktop",
  "referrer": "https://mail.google.com/",
  "recipient_slug": null,
  "recipient_ref": null,
  "recipient_label": null
}

Fires when the retention cron purges a link’s content. reason is expiry (the link reached its expiry time) or retention (its data-retention window elapsed after expiry).

data

{
  "slug": "k9m2p4q7r1s8t3v6",
  "title": "Q3 Proposal",
  "reason": "expiry",
  "expired_at": "2026-06-20T00:00:00.000Z"
}

usage.threshold

Fires from the daily usage cron, at most once per billing period, when your tracked-view usage crosses your configured alert threshold.

data

{
  "period": "2026-06",
  "tracked": 85000,
  "included": 100000,
  "blockRemaining": 0,
  "deficit": 0,
  "pct": 85,
  "threshold": 80
}

recipient.created

Fires when per-recipient tracked URLs are minted for a link — from the dashboard, REST, or the MCP create_recipient_links tool. One event per call, not one per recipient — a 500-recipient batch still fires a single delivery, with the full minted list in data.recipients.

data

{
  "parent_slug": "k9m2p4q7r1s8t3v6",
  "parent_url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "count": 2,
  "recipients": [
    { "recipient_ref": "crm-4471", "url": "https://htmlvault.io/k9m2p4q7-a1b2" },
    { "recipient_ref": "crm-4472", "url": "https://htmlvault.io/k9m2p4q7-c3d4" }
  ]
}

recipient.viewed

Fires on each view through a recipient-tracked URL — in addition to link.viewed, not instead of it. Subscribe to this one instead of filtering link.viewed if you only care about per-recipient traffic. It fires on every view, repeat visits included, not only the first. is_unique is true only for a viewer’s first counted visit, and visit_number is their running visit count (1 for a first visit, 2 for their next, and so on). Filter on either field to get a first-open-only signal, for example to notify a CRM the first time a specific prospect opens a link.

data

{
  "parent_slug": "k9m2p4q7r1s8t3v6",
  "recipient_slug": "k9m2p4q7-a1b2",
  "recipient_ref": "crm-4471",
  "recipient_label": "Jane at Acme",
  "view_id": "8f14e45f-ceea-4f7e-9c1b-2d3a4b5c6d7e",
  "is_unique": true,
  "visit_number": 1,
  "country": "United States",
  "city": "Austin",
  "browser": "Chrome",
  "os": "macOS",
  "device_type": "desktop",
  "referrer": "https://mail.google.com/"
}

Verifying signatures

The X-HTMLvault-Signature header is the hex HMAC-SHA256 of the raw request body, keyed with your endpoint’s signing secret (whsec_…, shown once when you create the endpoint). Two rules matter:

  • Compute the HMAC over the raw bytes received, before any JSON parsing or re-serialization — re-encoding can change bytes and break the match.
  • Compare with a timing-safe equality check, never ===.

Node.js (Express)

verify-webhook.js

const crypto = require('crypto');
const express = require('express');
const app = express();

// Capture the RAW body — required for signature verification.
app.use('/webhooks/htmlvault', express.raw({ type: 'application/json' }));

function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(signature || '', 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/htmlvault', (req, res) => {
  const sig = req.get('X-HTMLvault-Signature');
  if (!verify(req.body, sig, process.env.HTMLVAULT_WEBHOOK_SECRET)) {
    return res.status(401).send('bad signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Reject replays: the envelope timestamp must be recent.
  const ageMs = Date.now() - new Date(event.timestamp).getTime();
  if (ageMs > 5 * 60 * 1000) return res.status(401).send('stale');

  // event.id is stable across retries — dedupe on it.
  handle(event);
  res.sendStatus(200);
});

Python (Flask)

verify_webhook.py

import hmac, hashlib, time
from datetime import datetime, timezone
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = "whsec_your_secret"

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature or "", expected)

@app.post("/webhooks/htmlvault")
def webhook():
    raw = request.get_data()  # raw bytes, before parsing
    sig = request.headers.get("X-HTMLvault-Signature", "")
    if not verify(raw, sig, SECRET):
        abort(401)

    event = request.get_json()

    # Reject replays older than 5 minutes.
    ts = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00"))
    if (time.time() - ts.timestamp()) > 300:
        abort(401)

    handle(event)  # event["id"] is stable across retries — dedupe on it
    return "", 200

Always verify the signature first, then check the timestamp on the verified body. There is no separate timestamp header and no server-side replay window — the 5-minute check is enforced by your consumer.

Delivery & reliability

HTMLvault attempts delivery inline when the event fires, then retries failures from a background queue. Current delivery parameters:

FieldTypeDescription
Attempts4One inline attempt plus three retries.
Backoff+1m / +15m / +1hDelay before retry 2, 3, and 4 respectively.
Per-attempt timeout10sA request that doesn’t respond in time counts as a failure.
SuccessHTTP 2xxAny 2xx marks the delivery complete and resets the endpoint’s failure counter.
Auto-disable5 sequencesAfter 5 consecutive deliveries exhaust all attempts, the endpoint is disabled and the owner is emailed.

A disabled endpoint stops receiving events until you re-enable it from the Settings → Webhooks screen, which also resets its failure counter.

These values may be tuned over time; the figures above reflect the current configuration. Because retries reuse the exact original body and the stable id, a retried delivery verifies and deduplicates the same as the first attempt.

Sending to Slack

Any endpoint can be registered with the slack payload format instead of the standard JSON envelope. HTMLvault formats each event as a Slack Block Kit message, so a Slack incoming webhook URL just works — no Zapier or intermediate step required.

  1. In Slack, create an app for your workspace, enable Incoming Webhooks, click Add New Webhook to Workspace, choose a channel, and copy the URL.
  2. In HTMLvault, add a webhook endpoint with that URL and set Payload format to Slack.
  3. The X-HTMLvault-Signature header is still sent with every delivery, but Slack does not verify it — treat the webhook URL itself as the secret, the same way Slack does for anything else posting to it.

Payload format is set when the endpoint is created and can’t be edited afterward — delete and recreate the endpoint to change it.

Recipe: Slack alert on view

A no-code path to “ping me when my proposal gets opened”:

  1. In Zapier, create a Zap with a Webhooks by Zapier → Catch Hook trigger and copy its URL.
  2. Register that URL as an HTMLvault endpoint subscribed to link.viewed, and save the signing secret.
  3. (Recommended) Add a Code by Zapier step that verifies X-HTMLvault-Signature with the snippet above before continuing.
  4. Add a Slack → Send Channel Message action using fields from data — e.g. “{{data__title}} opened in {{data__country}}.”

The same pattern drives any catch-hook consumer (Make, n8n, a Lambda). Fuller end-to-end recipes are covered on the blog.

Consumer best practices

  • Verify every request with the signature before acting on it — treat an unverified body as untrusted input.
  • Respond fast (2xx) and process async. Acknowledge within the 10-second window, then do slow work on your own queue; a slow handler reads as a failed delivery and triggers retries.
  • Deduplicate on id. Retries and at-least-once delivery mean you may see the same id more than once; make your handler idempotent.
  • Reject stale events using the envelope timestamp (5-minute window above).
  • Subscribe narrowly. Pick only the events you handle rather than *, so a noisy event type (like link.viewed) doesn’t bury the ones you care about.
  • Keep your endpoint healthy. Five consecutive exhausted deliveries auto-disable it — monitor for the disable email and re-enable once you’ve fixed the receiver.

Creating links from an AI agent? See the MCP server reference.