MCP server
HTMLvault exposes a hosted Model Context Protocol (MCP) server so AI agents can publish and manage secure links directly, with no curl and no glue code. The endpoint is Streamable HTTP at https://htmlvault.com/api/mcp, and every plan can connect. Free accounts sign in over OAuth and get the publish, edit, read, analytics, delete and scan tools within the same limits as the web upload form. Static API keys, batch creation and recipient links need a paid plan (Pro, Teams or Enterprise).
| Endpoint | https://htmlvault.com/api/mcp |
| Transport | Streamable HTTP (JSON-RPC 2.0 over a single POST) |
| Auth | OAuth 2.1 sign-in (custom connectors), available on every plan including Free, or an Authorization: Bearer <key> API key, which requires Pro, Teams or Enterprise |
| Tools | list_links · get_link · get_analytics · create_link · create_draft_link · append_html · create_links · update_link · patch_link · delete_link · scan_html · create_recipient_links |
| Plans | Free: ten of the twelve tools, with publishing capped at 50 links a month and 30-day expiry. Pro, Teams and Enterprise: all twelve, unlimited links and any expiry. Full breakdown in Plans & limits. |
Quick start
OAuth-capable clients (claude.ai, Claude Desktop, Claude Code, ChatGPT) need no API key and no paid plan: you sign in with your HTMLvault account when the client prompts you, on Free exactly as on a paid plan. For everything else, generate an API key (Pro, Teams or Enterprise) on the Settings → API keys screen and replace hv_YOUR_KEY in the snippets below.
claude.ai & Claude Desktop (OAuth, recommended)
In Settings → Connectors → Add custom connector, paste the endpoint URL, then click Connect and sign in. No terminal, no config file, no key.
https://htmlvault.com/api/mcp
Claude Code
Run once in your terminal. You’ll be prompted to sign in on first use, and HTMLvault is then available in every Claude Code session. Prefer a static key (e.g. CI)? Append --header "Authorization: Bearer hv_YOUR_KEY".
claude mcp add --transport http htmlvault https://htmlvault.com/api/mcp
Claude Desktop: static-key bridge (advanced)
Only needed if you’d rather authenticate with an API key than OAuth: use the mcp-remote bridge in claude_desktop_config.json:
claude_desktop_config.json
{
"mcpServers": {
"htmlvault": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://htmlvault.com/api/mcp",
"--header", "Authorization: Bearer hv_YOUR_KEY"]
}
}
}Cursor
Add to ~/.cursor/mcp.json (or the project-level .cursor/mcp.json):
~/.cursor/mcp.json
{
"mcpServers": {
"htmlvault": {
"url": "https://htmlvault.com/api/mcp",
"headers": { "Authorization": "Bearer hv_YOUR_KEY" }
}
}
}Anthropic API (MCP connector)
Pass the endpoint in the mcp_servers array of a Messages API request (requires the MCP connector beta header):
{
"model": "claude-sonnet-4-6",
"mcp_servers": [
{
"type": "url",
"url": "https://htmlvault.com/api/mcp",
"name": "htmlvault",
"authorization_token": "hv_YOUR_KEY"
}
],
"messages": [{ "role": "user", "content": "Vault this report and give me the link." }]
}ChatGPT
ChatGPT (Plus/Pro/Team and up) connects over MCP: enable Developer Mode (Settings → Apps & Connectors → Advanced), add a custom connector pointing at the endpoint above, and sign in when prompted. Alternatively, a Custom GPT Action over the REST API works without Developer Mode. The import-ready OpenAPI schema and step-by-step setup live on the Settings → MCP screen.
Authentication
Every request carries a bearer token in the Authorization header, in one of two modes. OAuth 2.1: connector-style clients discover the authorization server via the RFC 9728 metadata at /.well-known/oauth-protected-resource/api/mcp and run a standard sign-in flow; access tokens are short-lived and refresh automatically. Static API keys: created and revoked on the Settings → API keys screen, shown once at generation time; the right choice for CI and server-side callers.
Authorization: Bearer hv_YOUR_KEY
Personal vs. org keys
OAuth sign-in works on every plan, Free included. Personal API keys require Pro, Teams or Enterprise. Either credential always acts as you, the signed-in user or key owner.
Org keys (Enterprise, created by an admin with api_keys.manage_org) instead act on behalf of the organization owner: every tool call is attributed to the owner’s account. Meant for server-to-server integrations with no logged-in user.
Either way, by default you only see your own (or, for an org key, the owner’s own) links. Pass scope: "all" on list_links, get_link, get_analytics, update_link, patch_link, or delete_link to widen to every org member’s links instead. This requires your role to hold the matching permission (links.view_any, links.edit_any, links.delete_any, or analytics.view_any); omitting scope falls back to your stored preference on Settings → Link defaults (default me). A role without the permission gets an explicit error, never a silent narrowing.
Auth errors
| Field | Type | Description |
|---|---|---|
missing_key | 401 | No Authorization header (or empty bearer). Carries a WWW-Authenticate challenge so OAuth-capable clients auto-launch sign-in. Message: “No credentials provided. OAuth-capable MCP clients will prompt you to sign in; otherwise include the header: Authorization: Bearer <key>. Create a key at https://htmlvault.com/connect” |
invalid_key | 401 | Key is not recognized or has been revoked. Message: “API key not recognized or has been revoked. Manage your keys at https://htmlvault.com/connect” |
invalid_token | 401 | OAuth access token is expired or invalid. Clients re-authenticate automatically via the WWW-Authenticate challenge. |
plan_required | 403 | A valid API key on a Free account. Keys need Pro, Teams or Enterprise; the OAuth path never returns this, so a Free account that signs in through a connector is admitted and meets its plan limits per tool instead. Message: “MCP access with an API key requires a paid plan (Pro, Teams or Enterprise). On Free, connect through OAuth sign-in instead.” |
upstream_error | 503 | A temporary failure on our identity provider's side: its signing keys couldn't be fetched to check the token, or identity resolution failed after the token checked out. Retry the same token after the Retry-After interval. |
email_unverified | 403 | OAuth token is valid, but the account’s email address is not verified. There is no WWW-Authenticate challenge, because signing in again cannot fix it. The body is a JSON-RPC error: { "jsonrpc": "2.0", "id": null, "error": { "code": -32001, "message": "...", "data": { "reason": "email_unverified" } } }. Verify the address by signing in at https://htmlvault.com with the code we email you, then reconnect. |
The 401 cases share a shape ({ "error": "...", "message": "..." }); the key-on-Free case is a distinct 403 so a client can tell “wrong key” from “upgrade needed.”
Plans & limits
The server itself is not gated. A Free account can add the connector, sign in and start publishing; what differs is the credential it can use and which tools answer. OAuth sign-in is available on every plan. Static API keys need Pro, Teams or Enterprise, so a Free account connects through a connector-style client (claude.ai, Claude Desktop, Claude Code, ChatGPT) rather than through a config file with a key in it.
Publishing limits are the same ones the web upload form applies, deliberately: a link created by an agent counts against the same monthly allowance as one created in the browser.
| Tool | Free | Pro, Teams & Enterprise |
|---|---|---|
create_link | Yes, within 50 links per month and a 30-day expiry ceiling | Yes, unlimited links, any expiry including never |
create_draft_link | Yes, and it costs nothing: a draft is charged only when finalized | Yes |
append_html | Yes. finalize: true is where the 50-per-month allowance is charged and where the 30-day expiry starts counting, so time spent drafting costs nothing | Yes, unlimited links; a finalized draft takes your account’s default expiry |
list_links | Yes | Yes |
get_link | Yes | Yes |
get_analytics | Yes, the same views, engagement and breakdowns | Yes |
delete_link | Yes | Yes |
scan_html | Yes | Yes |
update_link | Yes | Yes |
patch_link | Yes | Yes |
create_links | No, returns pro_required | Yes |
create_recipient_links | No, returns a plan error | Yes |
What a Free caller runs into
- Monthly cap. The 51st
create_linkin a calendar month returnslink_limit_reached, and nothing is created. Append-mode drafts are charged at the same counter, but only on theappend_htmlcall that finalizes, so a draft you cannot finalize yet is still there to retry, until its 24-hour window runs out. - Expiry ceiling. An
expiresInpast 30 days (includingnever) is capped at 30 days rather than rejected, so the call succeeds with a nearer expiry than asked for. A draft takes noexpiresInat all: the 30 days are counted fromcreate_draft_link, not from the finalize that published it. - No custom domain. The
domainparameter names an active custom domain, which is a paid feature, so on Free it returnsunknown_domain. Links serve from the default share domain. - Preview image stored, not shown. An
og:imagemeta tag in the submitted HTML is parsed and stored on any plan, sopreview_imageechoes back the URL, butpreview_image_activeisfalseuntil the account is on a paid plan. It starts displaying on its own once the plan changes, with no republish needed either way.
Nothing about a Free connection is downgraded silently apart from the expiry cap. Every other limit surfaces as an explicit error naming the plan, so an agent can report it rather than guess. Plan is re-resolved on every request, so an upgrade takes effect on the next tool call, with no reconnect and no new token.
Tool reference
Twelve tools, ten of them available on Free (see Plans & limits). Read-only tools (list_links, get_link, get_analytics, scan_html) are annotated readOnlyHint: true; delete_link is annotated destructiveHint: true. Every tool returns its JSON result inside the MCP content envelope as a text block, and (for clients that read structured output) as structuredContent validated against the tool’s declared outputSchema.
Four of them publish content: create_link (one document), create_links (up to 100 from one template), update_link (replace a document), and patch_link (change parts of a document). Pick the narrowest one that fits: an agent that re-sends a whole document to change a heading, or calls create_link in a loop, pays for every byte twice. A fifth path publishes across several calls rather than one: create_draft_link plus append_html assemble a large document server-side, chunk by chunk, and publish it on the final call.
scan_report
Those four publishing tools accept an optional scan_report parameter controlling how much PII-scan detail comes back on success. It is a response-verbosity switch only: it never changes what is scanned, gated, or blocked.
| Field | Type | Description |
|---|---|---|
scan_report | "summary" | "none" | Default summary: the success payload carries the non-blocking PII warnings (warnings on create_link / update_link / patch_link, rows_with_warnings on create_links). none omits that key entirely, so an agent that doesn’t act on warnings isn’t charged for reading them. |
Under scan_report: "none" the key is absent, not empty. On create_links, a present-and-empty rows_with_warnings means the batch ran and no row warned, while an absent one means reporting was suppressed. A blocked publish always returns its findings regardless of this parameter: scan_report can hide warnings, never the reason a publish was refused. append_html takes no scan_report: a finalize that publishes with non-blocking warnings always returns them.
list_links
List your links, newest first, with cursor pagination.
| Field | Type | Description |
|---|---|---|
limit | integer | Results per page. Default 20, maximum 100. |
cursor | string | Opaque pagination token. Pass next_cursor from a previous response to fetch the next page. |
created_after | string | ISO 8601 UTC timestamp (matches the format in created_at below): return only links created strictly after this time. |
created_before | string | ISO 8601 UTC timestamp (matches the format in created_at below): return only links created strictly before this time. |
scope | "me" | "all" | Default me (or your stored preference on Settings → Link defaults) lists only your own links. all lists every org member’s links instead (requires your role to hold links.view_any; returns an error otherwise rather than silently narrowing). |
Result
{
"links": [
{
"slug": "a1b2c3d4e5f6g7h8",
"title": "Q3 Proposal",
"created_at": "2026-06-10T18:22:04.511Z",
"expires_at": null,
"view_count": 42,
"is_public": true,
"url": "https://htmlvault.io/a1b2c3d4e5f6g7h8"
}
],
"next_cursor": "MjAyNi0wNi0xMFQxODoyMjowNC41MTFafGExYjJjM2Q0",
"has_more": true,
"viewer_timezone": "America/Denver"
}When has_more is true, call again with cursor set to next_cursor. When it is false, next_cursor is null and you have the last page. All timestamps are UTC; viewer_timezone is the IANA zone from the caller’s account settings (null if unset) so an agent can convert before showing a time to a person.
get_link
Fetch one link’s metadata and full stored HTML.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The link slug. |
scope | "me" | "all" | Default me (or your stored preference) only finds your own links. all also finds any org member’s link (requires links.view_any). |
Result
{
"slug": "a1b2c3d4e5f6g7h8",
"title": "Q3 Proposal",
"html_content": "<!doctype html>…",
"created_at": "2026-06-10T18:22:04.511Z",
"expires_at": null,
"view_count": 42,
"is_public": true,
"url": "https://htmlvault.io/a1b2c3d4e5f6g7h8",
"viewer_timezone": "America/Denver"
}Returns an error starting Link not found: if the slug is not owned by the caller (and scope wasn’t widened to all). The rest of it names the account that was searched, by its email address, and the slug, so a link created under a different HTMLvault account reads as exactly that. Every tool that takes a slug answers a miss the same way. Timestamps and viewer_timezone work the same as in list_links above.
get_analytics
View metrics for one link: totals, engagement, and breakdowns.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The link slug. |
period | "7d" | "30d" | "90d" | "all" | Time window for the metrics. Default 30d. (Unrelated to the scope parameter below: this all means all-time.) |
scope | "me" | "all" | Default me (or your stored preference) only finds your own links. all also finds any org member’s link (requires analytics.view_any). |
Result
{
"slug": "a1b2c3d4e5f6g7h8",
"period": "30d",
"totals": { "views": 318, "unique_views": 204, "repeat_views": 114, "bot_views": 27 },
"engagement": { "avg_time_on_page_seconds": 47, "avg_scroll_depth_pct": 68 },
"breakdowns": {
"countries": [{ "value": "United States", "views": 190 }],
"devices": [{ "value": "desktop", "views": 240 }],
"channels": [{ "value": "clay-icp-ceos", "views": 35 }],
"referrers": [{ "value": "https://mail.google.com/", "views": 60 }]
}
}Automated traffic is excluded from views, engagement, and every breakdown; bot hits are counted separately as bot_views so the number is visible without polluting the human totals. Metric definitions match the dashboard exactly. Returns an error starting Link not found:, naming the account searched and the slug, if the slug is not owned by the caller.
create_link
Publish HTML as a secure, shareable link. Every external link in it is checked against Google Web Risk, and the content against your account’s PII scan policy, before it goes live. Available on every plan: Free publishes up to 50 links a month with expiry capped at 30 days, paid plans are unlimited with any expiry. The link check only knows sites Google already flags, so it is not a guarantee: some unsafe sites may be missed, and some safe ones flagged in error.
| Field | Type | Description |
|---|---|---|
htmlRequired | string | Complete HTML to publish. 5 MB limit. |
titleRequired | string | Short descriptive title shown in your dashboard. |
expiresIn | "1h" | "24h" | "3d" | "7d" | "30d" | "never" | Expiry window. Omit to use your account default. |
tracking | "none" | Set to none to publish with no tracking pixels or default tracking code. Omit to apply your tracking defaults. |
domain | string | Hostname of one of your active custom domains to serve from. Omit to inherit your account default domain. |
scan_report | "summary" | "none" | Response verbosity: see scan_report above. none omits warnings from a successful publish. |
Result
{
"slug": "k9m2p4q7r1s8t3v6",
"url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
"title": "Q3 Proposal"
}If your account’s PII policy is set to warn (rather than block), the link still publishes and the response includes a warnings array describing the categories found. See Errors for the block cases. A successful MCP create_link fires the link.created webhook.
create_draft_link
Start an empty draft and get its slug: step one of append-mode publishing, for a document you intend to build up across several append_html calls instead of sending it in one. Nothing is checked, charged, or served yet: no publish gates run, a Free account’s monthly link allowance is untouched, the slug 404s publicly, and the draft is excluded from list_links. Available on every plan. Use it when a document is big enough that losing one 5 MB create_link call mid-transmit would hurt; for anything that fits comfortably in a single call, plain create_link is one round trip instead of several.
| Field | Type | Description |
|---|---|---|
titleRequired | string | Short descriptive title shown in your dashboard. |
Result
{
"slug": "k9m2p4q7r1s8t3v6",
"draft_expires_at": "2026-08-18T18:22:04.511Z"
}A draft lives as long as your account’s data-retention window, with a floor of 24 hours (90 days on the default setting, and the org owner’s window for an Enterprise member). (auto_delete resolves to the 24-hour floor rather than to zero, or a draft would be swept out from under the appends building it.) draft_expires_at is that deadline, not the published link’s expiry. A draft still unfinalized when it passes is deleted outright by the daily retention sweep, content and all. Mid-build you can read it with get_link, which returns the accumulated HTML alongside is_draft: true and the same draft_expires_at. The draft tools take no expiresIn: once finalized, the link takes your account’s default expiry on Pro, Teams and Enterprise (accounts created since October 2026 start with 1 year; change it under Settings, Link defaults), and on Free it expires 30 days after it is published. Either way the clock starts at finalize, not here, so however long you spend assembling the document costs nothing against it.
append_html
Append a chunk to a draft’s stored HTML. The server does the concatenation, so content already sent is never re-transmitted. Call it as many times as the document needs, then once more with finalize: true. That last call is what checks and publishes. Available on every plan.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The draft slug from create_draft_link. |
chunkRequired | string | HTML to append. 200 KB maximum per call, and the draft’s accumulated content may not pass the 5 MB document ceiling create_link enforces. |
finalize | boolean | Default false. Set true on the last call to run the publish gates and make the link live. |
Result, mid-build (finalize omitted or false)
{
"slug": "k9m2p4q7r1s8t3v6",
"content_length": 412800,
"content_hash": "9f2c1a…",
"finalized": false
}Result (finalize: true)
{
"slug": "k9m2p4q7r1s8t3v6",
"url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
"title": "Q3 Proposal",
"content_length": 498211,
"content_hash": "4b7e02…",
"changed": false,
"transformations": [],
"finalized": true
}Every call reports content_length and content_hash (SHA-256) of the draft’s full accumulated content, not of the chunk, so an agent can confirm each piece landed before sending the next. Between calls the draft is allowed to be incomplete and not well-formed: a half-open <table> at a chunk boundary is normal and is not an error.
Nothing is gated until finalize. The finalize: true call runs the same tail create_link runs, against the whole assembled document: well-formedness, publish-time normalization, your account’s PII policy, and the Google Web Risk link check. Only after those pass does it charge a Free account’s monthly link slot, so a blocked publish never burns one. On success the draft state is cleared, the slug goes live, and the link.created webhook fires (the first and only link.created for that slug).
A failed finalize leaves the draft intact. Malformed HTML, a PII block, unsafe content, or a Free account at its monthly cap all reject before anything is written, so the stored content stays byte-for-byte what it was: every chunk you already sent is still there. Inspect it with get_link, fix the offending part, and call finalize: true again. Because the rejected final chunk was never persisted, retry by resending that chunk.
Append-specific rejections: chunk_too_large (over 200 KB), draft_too_large (this chunk would pass 5 MB), not_a_draft (the slug is already finalized: edit it with update_link or patch_link instead), and draft_expired (past the 24-hour window; start a new draft). None of them append anything. If the PII policy is set to warn rather than block, finalize publishes and adds a warnings array, exactly as create_link does; see Errors for the block cases.
create_links
Publish up to 100 links in one call from a single HTML template plus per-row variable substitution: the efficient way to produce many distinct pages (a personalized report per customer, a per-account one-pager) without re-sending the document each time. Paid plans only (Pro, Teams, Enterprise). For many tracked URLs of the same unchanged page, use create_recipient_links instead.
| Field | Type | Description |
|---|---|---|
template_htmlRequired | string | Complete HTML template. {{name}} placeholders are replaced per row. 5 MB limit per substituted document; 20 MB across the whole batch. |
linksRequired | array (1–100) | One entry per link, each with a title (required) and an optional vars map of placeholder values, e.g. {"customer": "Acme Corp"}. |
expiresIn | "1h" | "24h" | "3d" | "7d" | "30d" | "never" | Expiry window applied to every link in the batch. Omit to use your account default. |
tracking | "none" | Set to none to attach no pixels or default tracking code to any link in the batch. |
domain | string | Hostname of one of your active custom domains to serve every link in the batch from. Omit to inherit your account default domain. |
scan_report | "summary" | "none" | Response verbosity: see scan_report above. none omits rows_with_warnings entirely. |
Request
{
"template_html": "<!doctype html><h1>{{customer}} — {{quarter}} review</h1>…",
"links": [
{ "title": "Acme — Q3", "vars": { "customer": "Acme Corp", "quarter": "Q3" } },
{ "title": "Globex — Q3", "vars": { "customer": "Globex Inc", "quarter": "Q3" } }
]
}Result
{
"created": [
{ "slug": "k9m2p4q7r1s8t3v6", "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6", "title": "Acme — Q3" },
{ "slug": "b4n7x2c9d1e5f8g3", "url": "https://htmlvault.io/b4n7x2c9d1e5f8g3", "title": "Globex — Q3" }
],
"count": 2,
"rows_with_warnings": [1]
}created is in the same order as the input rows. rows_with_warnings holds the zero-based indices of rows that published with non-blocking PII warnings (omitted under scan_report: "none").
Substitution rules. A {{token}} with no matching var in a row passes through untouched: templates containing Vue or Handlebars syntax are safe. The reverse is an error: a var with no matching token fails its row with unknown_var, so a typo like {{first_name}} vs firstName is caught instead of quietly publishing a page missing its personalization. Substituted values are never re-scanned for placeholders, so one row’s value cannot expand into another row’s token.
All-or-nothing. Any invalid row, any row blocked by your PII policy, and any flagged URL rejects the entire batch. Nothing is created. Failures come back in a rows array of { index, error, message?, detail? } so you can fix the offending rows and retry the whole call.
The gates run batched, not weakened: one Google Web Risk check covers the union of every row’s external URLs, each URL checked once however many rows link it, while the PII policy is evaluated per substituted document at full fidelity. Each created link fires its own link.created webhook. Batch creation draws on a per-account budget of 1,000 links per minute, counted in links rather than calls (see Limits & errors).
update_link
Replace the HTML of an existing link in place: the slug and public URL are unchanged, so a link you have already shared updates live. Re-runs the same Google Web Risk and PII policy gates as publishing, on every plan. For anything short of a full rewrite, prefer patch_link. It sends only the changed parts.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The link slug to update. |
htmlRequired | string | New HTML content. 5 MB limit. |
scope | "me" | "all" | Default me (or your stored preference) only edits your own links. all also allows editing any org member’s link (requires links.edit_any). |
scan_report | "summary" | "none" | Response verbosity: see scan_report above. none omits warnings from a successful update. |
Result
{
"slug": "k9m2p4q7r1s8t3v6",
"url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
"updated_at": "2026-06-13T15:04:11.882Z"
}Returns an error starting Link not found or not owned:, naming the account searched and the slug, if the slug isn’t yours (and scope wasn’t widened to all). Fires the link.updated webhook on success, against the link’s actual owner.
patch_link
Edit a published link by sending only the changed parts. Each patch replaces an exact substring of the stored HTML (the same find/replace semantics as an editor, or Claude Code’s Edit tool). Deliberately no regex: exact matching is the only contract an agent can reason about reliably. Same publish gates as update_link.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The link slug to patch. |
patchesRequired | array (1–200) | Each entry takes find (exact substring to replace, required, non-empty), replace (replacement text, required. An empty string deletes the match), and optional replace_all. Applied in order, each against the result of the previous. |
mode | "unique" | "positional" | Default unique: without replace_all, a find string that occurs more than once fails as ambiguous. positional skips that check and applies each patch to the first occurrence of its find string in the running document (see below). |
scope | "me" | "all" | Default me (or your stored preference) only edits your own links. all also allows editing any org member’s link (requires links.edit_any). |
scan_report | "summary" | "none" | Response verbosity: see scan_report above. none omits warnings from a successful patch. |
Patches match the stored document, not the HTML you sent. HTMLvault sanitizes content at publish time and serves the sanitized version, so that’s what a find string is compared against: exactly the html_content that get_link returns. If a patch surprises you with not_found, re-fetch with get_link and copy the find string from there.
In the default unique mode, without replace_all, the find string must occur exactly once. Include enough surrounding context to make it unique. The whole batch is all-or-nothing: the first failing patch aborts the call and nothing is persisted.
Request
{
"slug": "k9m2p4q7r1s8t3v6",
"patches": [
{ "find": "<h1>Q3 Proposal</h1>", "replace": "<h1>Q4 Proposal</h1>" },
{ "find": "Draft — do not circulate", "replace": "", "replace_all": true }
]
}Result
{
"slug": "k9m2p4q7r1s8t3v6",
"url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
"updated_at": "2026-07-24T15:04:11.882Z",
"patches_applied": 2,
"replacements": 4
}patches_applied counts the patches; replacements counts the substrings actually replaced across all of them (higher when replace_all matched more than once).
Two situations defeat uniqueness however much context you include, and both call for mode: "positional". Rebuilt, minified output, where identifier mangling makes short strings recur by construction. And several structurally identical blocks (repeated cards or rows), where a find string that was unique against the original document turns ambiguous mid-call, once an earlier patch has changed one of the copies. In positional mode each patch applies to the first occurrence of its find string in the running document, so order your patches to match the order the target text appears. not_found still fails the call, and replace_all still replaces every occurrence.
Positional request
{
"slug": "k9m2p4q7r1s8t3v6",
"mode": "positional",
"patches": [
{ "find": "<span class=\"price\">$10</span>", "replace": "<span class=\"price\">$12</span>" },
{ "find": "<span class=\"price\">$10</span>", "replace": "<span class=\"price\">$15</span>" }
]
}A patch that can’t apply returns enough detail to repair the request without guessing:
Patch failure
{
"error": "patch_failed",
"failed_patch": 1,
"reason": "ambiguous",
"occurrences": 3,
"message": "Patch at index 1: the find string occurs 3 times — include more surrounding context to make it unique, or set replace_all: true to replace every occurrence."
}reason is not_found (occurrences: 0) or ambiguous (more than one occurrence without replace_all, unique mode only), and failed_patch is the zero-based index into your patches array. A link whose content has been purged by your retention policy returns content_unavailable: there is nothing stored to patch against, so use update_link with the full document instead. On success, fires the link.updated webhook, same as update_link.
delete_link
Delete one of your links.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The link slug to delete. |
scope | "me" | "all" | Default me (or your stored preference) only deletes your own links. all also allows deleting any org member’s link (requires links.delete_any). |
Deletion is immediate and irreversible. The link row is hard-deleted and all of its view records are removed with it (cascade). It does not pass through the retention window. There is no recovery period. A link under a legal hold cannot be deleted.
Result
{ "deleted": "k9m2p4q7r1s8t3v6" }A link under legal hold returns { "error": "legal_hold", "message": "This link is under a legal hold and cannot be deleted." }. A slug you don’t own (or already deleted) returns an error starting Link not found or already deleted:, naming the account searched and the slug.
scan_html
Run the regex PII & secret scanner against HTML without publishing, for content you are not (yet) publishing. You do not need to call this before a publish: the four publishing tools run this same scan plus the Google Web Risk link check server-side and reject blocked content with the findings in the error, so pre-scanning doubles the work for no added safety.
| Field | Type | Description |
|---|---|---|
htmlRequired | string | HTML content to scan. |
Result
{
"hasSensitiveData": true,
"findings": [
{ "type": "api_key", "label": "API Key", "count": 1 },
{ "type": "email", "label": "Email Address", "count": 3 }
]
}This is the zero-token regex scanner only: it reports findings but never blocks. The publish gates on create_link, create_links, update_link, and patch_link are what enforce your policy.
create_recipient_links
Mint per-recipient tracked URLs for a link you own. Batch up to 500 in one call. Each recipient gets a unique slug that resolves to the parent link’s HTML and stamps individual attribution into view records. A supplied email is never stored: it is kept only as a keyed HMAC-SHA256 hash. Paid plans only (Pro, Teams, Enterprise); Free accounts are refused.
| Field | Type | Description |
|---|---|---|
slugRequired | string | The parent link slug to mint recipients for. |
recipientsRequired | array (1–500) | Each entry accepts ref (opaque external ID, not an email), label (display name), email (hashed before storage), and per-recipient utm_source / utm_medium / utm_campaign / utm_term / utm_content overrides, all optional. |
Result
{
"recipients": [
{ "recipient_ref": "crm-4471", "url": "https://htmlvault.io/k9m2p4q7-a1b2" }
]
}Returns { "error": "..." } on a Free-plan account, on an org role lacking recipient permissions, or when the parent slug isn’t owned by the caller. A successful call fires the recipient.created webhook once per call (not once per recipient).
Workflow recipes
Publish straight from a chat
Ask the agent to generate an HTML artifact (a proposal, dashboard, or one-pager), then “vault this.” The agent calls create_link with the HTML and a short title and returns the URL. Because the publish gates run server-side, anything that trips your PII policy or links to a site Google Web Risk flags is caught before the link exists.
Living dashboards
Publish a status page once with create_link, share the URL, then have the agent refresh it on a schedule with patch_link, swapping just the numbers rather than re-uploading the page every time (update_link is there for the occasional full rewrite). The slug never changes, so recipients always see current data at the same link, and get_analytics tells you who’s actually opening it.
Personalized runs
Building the same report for 80 accounts? Write the page once with {{account}}-style placeholders and hand it to create_links with one row per account: the template crosses the wire once instead of 80 times, and you get 80 slugs back in a single response. If the page content is identical for everyone and you only need to know who opened it, publish once and mint tracked URLs with create_recipient_links instead.
Documents too big for one call
A 4 MB report is one create_link call that either lands or is lost. Instead, open a draft with create_draft_link, stream it in with append_html a section at a time (checking content_length as you go), and publish with finalize: true on the last chunk. A dropped connection costs one chunk, not the document, and the gates still see the whole page at the end. The draft is not reachable by anyone until they pass.
Agent pipelines with notifications
When an automated pipeline creates links via MCP, subscribe your stack to the matching webhook events: link.created to log the new URL, link.viewed to react to engagement. The agent publishes; your systems are notified out-of-band without polling.
Limits & errors
Rate limit
Requests are rate-limited per credential to 100 requests per minute (fixed one-minute window): per API key, or per signed-in user across all their OAuth clients. Over the limit returns 429 with a Retry-After header:
429 Too Many Requests
{ "error": "rate_limited", "message": "Too many requests. Please slow down and retry after the window resets." }The exact ceiling may be tuned over time; clients should honor Retry-After rather than hard-code the value.
Batch budget
create_links carries a second, row-weighted limit on top of the per-key request limit: 1,000 links per minute per account. It counts links, not calls (ten calls of 100 rows exhaust it just as one call of 1,000 would), and it is keyed per account, so issuing extra API keys does not widen it. Over budget returns a rate_limited tool result carrying retryAfterSeconds, and nothing is created.
Error catalog
Tool-level errors are returned as a JSON object in the content envelope (transport status 200); auth and rate-limit errors are returned at the HTTP layer with the status shown. Publish-gate blocks (pii_detected, pii_blocked, unsafe_content, too_many_urls, unverifiable_content, safety_check_unavailable, policy_unavailable, document_too_large, and the Free plan’s link-check rate_limited) additionally set the MCP isError flag on the result.
| Field | Type | Description |
|---|---|---|
pii_detected | tool result | PII policy is set to block and the content matched. Shape: { "error": "pii_detected", "message": "...", "findings": [...], "rule": "...", "riskScore": 26 }. message names the matched categories and the blocking rule. From create_link / update_link / patch_link, and from append_html with finalize: true. |
pii_blocked | tool result | The create_links equivalent: one or more rows tripped a block rule, so the whole batch was rejected. Per-row detail (findings, rule, risk score) rides the rows array; nothing was created. |
unsafe_content | tool result | Google Web Risk flags a link in the content as suspected phishing, malware, or unwanted software. Shape: { "error": "unsafe_content", "message": "...", "threatTypes": [...], "advisory": { "text": "Advisory provided by Google", "url": "..." }, "learnMore": [{ "label": "...", "url": "..." }] }. The message already carries Google’s attribution and learn-more links. If you show the warning to a person, keep both: Google’s Web Risk terms require them, and a flag is a suspicion, not a certainty. From create_links it also carries matched_urls and the rows that referenced them. |
too_many_urls | tool result | The content links to more distinct external URLs than one publish can check against Google Web Risk: 100 on the Free plan, 500 on paid plans, 2,000 for a whole create_links batch (1,000 on Pro, its day of link checks). For an edit, only the URLs it adds count. Shape: { "error": "too_many_urls", "message": "...", "urlCount": 612, "limit": 500 }. Nothing was published; remove links or split the content. |
unverifiable_content | tool result | The content nests documents (an iframe srcdoc, or a data: HTML or SVG document) more than 3 levels deep, or puts more markup inside such documents than can be read, so the links inside could not all be read and checked against Google Web Risk. Shape: { "error": "unverifiable_content", "message": "..." }. From create_links it also carries the rows that nest too deep. Nothing was published; reduce the nesting. |
safety_check_unavailable | tool result | Free plan only. Google Web Risk could not check every link just now (an outage, a timeout, or the checks HTMLvault sets aside for Free publishing are used up for now), and a Free publish only goes live once every link has been checked. Shape: { "error": "safety_check_unavailable", "message": "..." }. Nothing was published and a draft is left intact; retry after the wait its message gives (usually a minute). On a paid plan the publish goes through instead. Over REST this is HTTP 503 with Retry-After. |
policy_unavailable | tool result | Your account’s PII publishing rules could not be checked just now (a temporary error on our side), and content is only published once they have been. Shape: { "error": "policy_unavailable", "message": "..." }. Nothing was published and a draft is left intact; retry in about 30 seconds. From every publishing tool, create_links included. Over REST this is HTTP 503 with Retry-After. |
document_too_large | tool result | The document as it would be stored is over 5 MB. The limit counts bytes after cleanup, so content under 5 MB as sent can still go over: each bare & is stored as &, and a non-ASCII character takes 2 to 4 bytes. Shape: { "error": "document_too_large", "message": "...", "bytes": 5400000, "limit": 5242880 }. From every publishing tool; create_links names the failing rows inside invalid_rows. Nothing was published and a draft is left intact. Over REST this is HTTP 413. |
unknown_domain | tool result | The domain passed to create_link or create_links isn’t an active domain on your account. |
patch_failed | tool result | A patch_link patch could not apply. Shape: { "error": "patch_failed", "failed_patch": 1, "reason": "ambiguous", "occurrences": 3, "message": "..." }. reason is not_found or ambiguous. Nothing was persisted. |
content_unavailable | tool result | From patch_link on a link whose stored HTML was purged by your retention policy: there is no document to patch against. Use update_link with the full HTML instead. |
invalid_rows | tool result | From create_links: one or more rows failed validation before any gate ran, most often unknown_var (a var with no matching {{token}}) or row_too_large. The rows array names each failing index; nothing was created. |
too_many_rows / batch_too_large / template_too_large | tool result | From create_links: more than 100 rows, more than 20 MB of content across the batch (as substituted, or as it would be stored), or a template over the 5 MB document limit. |
pro_required | tool result | Free plan calling a paid-only tool: create_links. create_recipient_links rejects the same case as a plain error message rather than this code. (Distinct from the HTTP 403 plan_required, which stops a key-authenticated Free caller before any tool runs; an OAuth-authenticated Free caller connects and gets these instead.) |
link_limit_reached | tool result | Free plan at its 50-links-per-calendar-month ceiling on create_link, or on the append_html call that finalizes a draft. Shape: { "error": "link_limit_reached", "message": "..." }. Nothing was created, and a draft is left intact. |
chunk_too_large / draft_too_large | tool result | From append_html: the chunk is over the 200 KB per-call limit, or appending it would push the draft past the 5 MB document ceiling. Nothing was appended. |
not_a_draft / draft_expired | tool result | From append_html: the slug is a finalized link (use update_link or patch_link), or the draft passed its 24-hour window and was removed (start a new one with create_draft_link). |
malformed_html | tool result | Publishing requires balanced tags. Carries reason, tag, position, and a context slice of the document around the problem. From create_link / update_link / patch_link, and from a finalizing append_html, where the assembled draft, not the chunk, is what is checked. |
invalid_cursor / invalid_created_after / invalid_created_before | tool result | From list_links: the cursor is not a next_cursor this server returned, or a date filter is not an ISO 8601 timestamp (a date alone, such as 2026-07-22, reads as midnight UTC). Shape: { "error": "invalid_cursor", "message": "..." }. Pass next_cursor back unchanged; nothing was listed. |
legal_hold | tool result | From delete_link on a held link. Shape: { "error": "legal_hold", "message": "..." }. |
scope: "all" denied | tool result | Passed scope: "all" (or it’s your stored default) on list_links, get_link, get_analytics, update_link, patch_link, or delete_link without the matching *_any permission on your role. Never a silent fallback to your own links. Returns { "error": "scope: 'all' requires the '...' permission, which your role does not hold." }. |
rate_limited | 429 / tool result | Per-key request limit exceeded (429), or the per-account create_links row budget exhausted (tool result with retryAfterSeconds); see above. Also a publish or edit that would check new links against Google Web Risk after the account has used its link checks: on the Free plan 100 a minute and 200 a day, on Pro 1,000 a day, on Teams 5,000 and on Enterprise 10,000 (days counted from midnight Pacific time); its message says how long to wait, and nothing was published. |
missing_key / invalid_key | 401 | No bearer header / unrecognized or revoked key. |
plan_required | 403 | Valid API key on a Free account; keys need Pro, Teams or Enterprise. OAuth callers never see this. |
email_unverified | 403 | OAuth caller whose email address is not verified. Sign in at https://htmlvault.com with the code we email you, then reconnect. |
Subscribing to events instead of polling? See the Webhooks reference.