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 5 endpoints on Pro, or 20 on Teams and Enterprise, on the webhook settings screen; each one gets its own signing secret. Webhooks need a paid plan.

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 event was queued. Retries resend the original body, so it is the same on every attempt, and a valid delivery can arrive well after it. Don’t reject an event for being old; deduplicate on id instead (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,
  "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', async (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'));

  // event.id is stable across retries, and a replayed request repeats it:
  // act on each id once. rememberEventId() records the id and returns true
  // only the first time (e.g. INSERT ... ON CONFLICT DO NOTHING).
  if (await rememberEventId(event.id)) handle(event);

  // Answer repeats with a 2xx too. Don't reject on event.timestamp: it is
  // when the event was queued, retries resend it unchanged, and a valid
  // delivery can arrive hours or days later.
  res.sendStatus(200);
});

Python (Flask)

verify_webhook.py

import hmac, hashlib
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()

    # event["id"] is stable across retries, and a replayed request repeats it:
    # act on each id once. remember_event_id() records the id and returns True
    # only the first time (e.g. INSERT ... ON CONFLICT DO NOTHING).
    if remember_event_id(event["id"]):
        handle(event)

    # Answer repeats with a 2xx too. Don't reject on event["timestamp"]: it is
    # when the event was queued, retries resend it unchanged, and a valid
    # delivery can arrive hours or days later.
    return "", 200

Always verify the signature first. It covers the whole body, id and timestamp included, so neither can be altered, but a captured request stays valid if someone sends it again. Deduplicating on id is the replay protection: a replayed request repeats an id you have already processed, and so does every retry.

Don’t reject a delivery for its age. timestamp is when the event was queued, and every retry carries the same one: retries normally arrive within about two hours of it, and deliveries held while an endpoint was disabled resume when it is re-enabled, up to 30 days after their event. A rejection (any non-2xx response) counts as a failed delivery, is retried, and enough of them disable your endpoint. If you want to stop remembering ids after a while, ignore events older than a cutoff of at least 24 hours, keep ids at least that long, and answer an ignored event with a 2xx.

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. Retries run from a queue served every 15 minutes, so each can land up to 15 minutes after its delay.
Per-attempt timeout10sThe whole attempt, from connecting to receiving your response status, must finish within 10 seconds or it counts as a failure. Only the status is read; your response body is ignored.
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. Events that fire while it is disabled are not queued; deliveries that were still waiting for a retry are kept for up to 30 days and resume when you re-enable it.

These values may be tuned over time; the figures above reflect the current configuration. Retries resend the exact original body, with the same id and timestamp, so a retried delivery verifies the same as the first attempt and deduplicates on its id.

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, at-least-once delivery and replayed requests all repeat an id; record the ids you process and answer a repeat with a 2xx without acting on it again.
  • Don’t reject old events. The envelope timestamp is when the event was queued and is the same on every retry, so a valid delivery can be hours old. A rejection counts as a failed delivery and can get your endpoint disabled. If you ignore events past a cutoff, make it at least 24 hours and still answer with a 2xx.
  • 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.