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, read, analytics, delete and scan tools within the same limits as the web upload form. Static API keys, editing after publish, batch creation and recipient links are Pro and 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 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: eight of the twelve tools, with publishing capped at 50 links a month and 30-day expiry. Pro 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 Pro or Enterprise. For everything else, generate an API key (Pro 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 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_linkto 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 are Pro and Enterprise only; 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 requires a Pro or Enterprise plan. Upgrade at https://htmlvault.com/pricing” |
upstream_error | 503 | OAuth token verified but identity resolution hit a temporary upstream failure. Retry after the Retry-After interval. |
The 401 cases share a shape ({ "error": "...", "message": "..." }); the key-on-Free case is a distinct 403so 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 are Pro and Enterprise only, 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 & 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 never expires |
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 | No, returns pro_required | Yes |
patch_link | No, returns pro_required | 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_reachedwith anupgradeUrl, 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 editing after publish.
update_linkandpatch_linkboth returnpro_required. Get the document right before callingcreate_link, or delete and republish, which spends another link from the allowance. - 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. Because Free cannot edit after publish, put the tag in the HTML up front: it starts displaying on upgrade with no republish.
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, eight 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 structuredContentvalidated 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_timezoneis 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 { "error": "Link not found" } if the slug is not owned by the caller (and scope wasn’t widened to all). 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 { "error": "Link not found" } if the slug is not owned by the caller.
create_link
Publish HTML as a secure, shareable link. Content is checked against Google Safe Browsing and 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, Pro and Enterprise are unlimited with any expiry.
| 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 and do not inherit your account default expiry: once finalized, the link never expires on Pro and Enterprise, and on Free it expires 30 days after it is published — the ceiling is stamped 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 Google Safe Browsing — and 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. Pro/Enterprise feature. 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 firstNameis 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: oneGoogle Safe Browsing call covers the union of every row’s external URLs, 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 Safe Browsing and PII policy gates as publishing. Editing after publish is a Pro/Enterprise feature. 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 { "error": "Link not found or not owned" } if the slug isn’t yours (and scope wasn’t widened to all). Fires the link.updatedwebhook 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 Pro/Enterprise requirement and same publish gates as update_link.
| Field | Type | Description |
|---|---|---|
slugrequired | string | The link slug to patch. |
patchesrequired | array (1–20) | 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. |
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.
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).
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), 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 { "error": "Link not found or already deleted" }.
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 Safe Browsing 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 hashed with SHA-256 before storage — never kept in plain text. Pro/Enterprise feature; Free accounts are blocked.
| 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 Safe Browsing 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_analyticstells 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) 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 | Content links to a known-malicious site (Safe Browsing). Shape: { "error": "unsafe_content", "message": "...", "threatTypes": [...] }. From create_links it also carries matched_urls and the rows that referenced them. |
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 substituted content across the batch, or a template over the 5 MB document limit. |
pro_required | tool result | Free plan calling a paid-only tool: update_link, patch_link, or 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": "...", "upgradeUrl": "https://htmlvault.com/upgrade" }. 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. |
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. |
missing_key / invalid_key | 401 | No bearer header / unrecognized or revoked key. |
plan_required | 403 | Valid API key on a Free account; keys are Pro and Enterprise only. OAuth callers never see this. |
Subscribing to events instead of polling? See the Webhooks reference.