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": { … }
}| Field | Type | Description |
|---|---|---|
id | string (UUID) | Stable event ID. Identical across every retry of the same delivery — use it to deduplicate. |
event | string | The event type, e.g. link.created. |
timestamp | string (ISO 8601) | When the delivery was enqueued. Verify against this to reject replays (see Signing). |
data | object | Event-specific payload. Shapes below. |
Three headers accompany every request:
| Field | Type | Description |
|---|---|---|
X-HTMLvault-Signature | header | Hex HMAC-SHA256 of the raw request body, signed with your endpoint secret. |
X-HTMLvault-Event | header | The event type (also in the body). |
X-HTMLvault-Delivery | header | A 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.
link.created
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"
}link.updated
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"
}link.viewed
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/"
}link.expired
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 "", 200Always 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:
| Field | Type | Description |
|---|---|---|
Attempts | 4 | One inline attempt plus three retries. |
Backoff | +1m / +15m / +1h | Delay before retry 2, 3, and 4 respectively. |
Per-attempt timeout | 10s | A request that doesn’t respond in time counts as a failure. |
Success | HTTP 2xx | Any 2xx marks the delivery complete and resets the endpoint’s failure counter. |
Auto-disable | 5 sequences | After 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”:
- In Zapier, create a Zap with a Webhooks by Zapier → Catch Hook trigger and copy its URL.
- Register that URL as an HTMLvault endpoint subscribed to
link.viewed, and save the signing secret. - (Recommended) Add a Code by Zapier step that verifies
X-HTMLvault-Signaturewith the snippet above before continuing. - 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 sameidmore 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 (likelink.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.