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. Seven 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.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
}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
}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 "", 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 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.
- In Slack, create an app for your workspace, enable Incoming Webhooks, click Add New Webhook to Workspace, choose a channel, and copy the URL.
- In HTMLvault, add a webhook endpoint with that URL and set Payload format to Slack.
- The
X-HTMLvault-Signatureheader 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”:
- 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.