What webhooks do
You've shared a proposal link with a prospect. Refreshing the analytics dashboard every ten minutes isn't a workflow; it's a distraction. And if link activity should drive other systems (a Slack alert, a CRM update, a lead score), polling an API on a schedule means delays, wasted requests and brittle scripts.
Webhooks flip the model. A webhook is an HTTP POST that HTMLvault sends to a URL you own the moment something happens to one of your links: it is created, edited, viewed, or reaches its expiry. Point it at a Zapier or Clay webhook trigger, a small function of your own, or a Slack incoming webhook, and a view starts the next step with nobody watching a dashboard. That step might be an activity logged in Salesforce or HubSpot, a row enriched in Clay, or a message in the deal channel while the prospect is still scrolling.
Any stack that can accept an HTTPS POST can receive an event. A RevOps team can route proposal views into the CRM, a marketer can start a follow-up sequence when a campaign page is read, and a developer can feed link activity into internal tools, all with the same mechanism.
The seven events
Each endpoint subscribes only to the events it needs:
link.created: a new link is published, whether from the web editor, the REST API or an AI tool through the MCP server.link.updated: a link's HTML content changes, either replaced whole or patched in place by an agent. Because editing published HTML keeps the URL, this event is how downstream systems learn that the page they point to has new numbers.link.viewed: someone opens a link. Bot views are never sent.link.expired: a link reaches its expiry, which makes a good trigger for a "your quote has lapsed" follow-up. See choosing an expiry window.recipient.created: recipient tracked links (one per person) are minted for a link.recipient.viewed: a recipient-tracked link is viewed, so the signal is "this person opened their copy" rather than "someone opened the proposal."usage.threshold: your tracked-view usage crosses your alert threshold.
Choosing * subscribes the endpoint to every event. In the Standard JSON format, every event arrives in the same signed envelope carrying the event type, the link it concerns, an event id and a timestamp. View events add metadata such as device, geo and referrer, but never the page content. Full payloads are on the webhooks reference page.
Set up an endpoint
Webhooks are available on Pro, Teams and Enterprise. On Free, the settings page shows an upgrade card instead of the form.
- Stand up the receiver first and confirm it returns 200 to a plain test POST. Fix reachability before you add signature checks.
- Open the Webhooks page in Settings. In the form card (headed Add a webhook, or Add another once one exists), paste your URL into Endpoint URL. Keep tokens out of its query string, because the signature already does the authenticating.
- Pick a Payload format. Standard JSON, the default, sends the signed event envelope to your code, Zapier or Clay. Slack formats the same event as a Block Kit message that a Slack incoming webhook posts straight to its channel. If you paste a
hooks.slack.comURL, the select switches to Slack on its own. The format is fixed when the endpoint is created. - Under Events, select what this endpoint should receive.
link.createdis selected by default, and selecting*clears the others. - Click Create webhook. A green Webhook created banner shows the signing secret, which starts with
whsec_. Copy it into your receiver's environment or secret manager, not into source control. It is shown once and cannot be displayed again. - Publish a throwaway link, open it, and confirm the event arrives and verifies.
Example: a Slack message on every proposal view
A rep wants the deal channel to know the moment a prospect opens a pricing proposal. Create a Slack incoming webhook for that channel, paste its URL as the endpoint (the format flips to Slack), select link.viewed, and click Create webhook. Nothing needs to sit in between, whether a relay or your own code. The endpoint fires for every link view, not just one proposal, so point it at a channel meant for that stream. To log the same views as CRM activities, create a second endpoint in Standard JSON pointed at a Zapier or Clay webhook trigger and map the fields there.
Verify the signature
Anyone who learns an endpoint URL can send requests to it, and a forged POST claiming "proposal viewed" can put a fake activity in your CRM. The signature lets your receiver reject anything HTMLvault didn't send. Every request carries an X-HTMLvault-Signature header containing the hex-encoded HMAC-SHA256 of the raw request body, keyed with that endpoint's whsec_ secret. HMAC-SHA256 is a keyed hash: only someone holding the same secret can produce the same digest, so a match proves the request came from HTMLvault and was not altered in transit.
Take the body exactly as received, before any JSON parsing. Compute the HMAC with your secret and compare it to the header in constant time. In Node.js with Express:
const crypto = require("crypto");
const express = require("express");
const app = express();
function verify(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps req.body as the exact bytes HTMLvault signed
app.post("/webhooks/htmlvault", express.raw({ type: "*/*" }), (req, res) => {
const signature = req.get("X-HTMLvault-Signature");
if (!verify(req.body, signature, process.env.HTMLVAULT_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
// store the event for a worker (see below), then answer
res.sendStatus(200);
});
- Hash the raw bytes. Parsing and re-serializing the JSON changes whitespace and key order, and then the digest no longer matches. When every signature fails, the usual cause is a body parser running before the check.
- Compare in constant time. Use
timingSafeEqualin Node orhmac.compare_digestin Python (the webhooks documentation has a Python version) so the comparison doesn't leak how many characters matched. - Answer a mismatch with 401. HTMLvault counts it as a failed attempt, so a receiver holding a stale or wrong secret shows up as failing deliveries instead of quietly dropping real events.
How delivery and retries work
When an event happens, HTMLvault builds its envelope once, with the event id and the timestamp of the moment it happened. It signs the envelope and POSTs it to every active endpoint subscribed to that event.
Each attempt has a 10-second deadline, and only the response status counts. Any 2xx means delivered, and the response body is ignored. A redirect, a 4xx, a 5xx, a refused connection or no answer within 10 seconds is a failed attempt.
A failed delivery is retried three times, after 60 seconds, 15 minutes and 1 hour. Every retry resends the identical signed body, timestamp included. A 2xx at any point ends the delivery. If the third retry also fails, the delivery is exhausted.
Delivery is therefore at-least-once. If your handler finishes its work but misses the deadline, HTMLvault records a failure and sends the event again. Arrival order is not event order either: a retried link.viewed can land after a newer link.updated, so sort by the envelope's timestamp, never by when a request arrived.
Build a receiver that survives retries
A receiver built for this schedule does four things:
- Answer 2xx fast. Verify, store, return 200. Leave the CRM call, the enrichment and the Slack post to a background worker. A receiver that does slow work inside the request causes the very retries it then has to deduplicate.
- Queue the work durably. Once you return a 2xx, HTMLvault considers the event delivered and will not send it again. Before answering, write it somewhere that survives a crash (a database table works).
- Deduplicate on the event id. Store events under a unique key on the event id. If a retry arrives for an event you already hold, treat it as a success: return 200 and do nothing. Returning an error for a duplicate counts as a failed attempt.
- Never reject an event for an old timestamp. The timestamp is set once, when the event is created, and a retry an hour later carries the same value. A freshness rule such as "reject anything older than five minutes" turns every legitimate retry into a failure and pushes the endpoint toward auto-disable. Your replay protection is the event-id check.
on POST /webhooks/htmlvault:
if not valid_signature(raw_body, headers["X-HTMLvault-Signature"], secret):
return 401 # counts as a failed attempt
event = parse_json(raw_body)
insert_if_absent(inbox, key=event_id(event), value=event)
# inbox: a table with a unique key on the event id; a worker processes new rows
return 200 # new or duplicate, well inside 10 seconds
Before you rely on it, replay one captured request (body and signature header) against the receiver and confirm the second copy changes nothing.
Auto-disable, re-enable, and limits
HTMLvault does not chase a dead endpoint forever. After five consecutive deliveries exhaust their retries, the endpoint is disabled and its owner gets an email. The count is consecutive: one delivery that succeeds, even on its last retry, resets it. An endpoint that fails now and then stays on. One whose server was retired, whose tunnel closed or whose secret went stale stops receiving traffic, and someone hears about it.
Fix the cause first, then open the webhooks settings page, where a disabled endpoint's row shows Re-enable before Delete. A disabled endpoint receives nothing, so after re-enabling it, check per-link analytics (or call get_analytics through MCP) for views that happened in the gap. Analytics keep recording whatever state your webhooks are in.
- Endpoints: up to 5 on Pro, and up to 20 on Teams and Enterprise. The list header shows your count against the cap, such as (1/5). At the cap, the add form is replaced by a "Maximum of 5 webhooks reached" line.
- Free plan: endpoints created on a paid plan stay listed with a Paused pill. Nothing is sent to them and you can only delete them. Upgrading resumes delivery.
- No edits: an endpoint's URL, format and events are set at creation. To change any of them, or to replace a lost or exposed secret, delete the endpoint and create a new one, which comes with a new secret.
Set up this way, a proposal view becomes a CRM activity seconds after the prospect opens the link. And on the day a receiver breaks, its owner gets an email instead of finding a gap in the pipeline weeks later.
