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. Five 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.

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/"
}

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
}

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 webhook settings 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.

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.