Publishing documents

POST /api/v1/links/document turns a document into a finished page and publishes it as a link: an HTML fragment with its figures, or a Word file. The page gets one fixed theme, light and dark, with no HTMLvault branding, so it serves the same on your own domain. Send the document again with the link's slug and the same link updates. It uses an API key, so it needs Pro, Teams or Enterprise.

The request

Send Authorization: Bearer hv_… with a JSON body, or with multipart/form-data when you upload a Word file (see Word files). Without slug the call creates a link and answers 201; with it, the call republishes that link and answers 200.

Create

curl -X POST https://htmlvault.com/api/v1/links/document \
  -H "Authorization: Bearer hv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Q3 plan",
    "html": "<h1>Q3 plan</h1><p>Where we are.</p><figure data-figure=\"flow\"><figcaption>How a request moves</figcaption></figure>",
    "figures": {
      "flow": { "mermaid": "flowchart LR\n  A[Request] --> B[Review] --> C[Ship]", "alt": "Request, review, ship" }
    },
    "source": { "kind": "notes_app", "ref": "doc-42", "rev": 7, "url": "https://example.com/docs/42" }
  }'
FieldTypeDescription
htmlstringThe document body as an HTML fragment: no <html>, <head> or <body>. Figures are marked by placeholders (see Figures). Send exactly one of html, html_base64 and docx_base64, or a Word file.
html_base64stringThe same fragment as base64, for a tool that hands you base64 it can’t decode. It must decode to UTF-8, and the decoded size counts against the 5 MB page limit.
docx_base64stringA Word (.docx) file as base64, converted on the server. Prefer html when you already have the content: base64 adds a third to the size.
titlerequiredstringThe link’s title, and the page’s <title> and og:title.
figuresobjectKeyed by placeholder id; required when the fragment has placeholders. Each value holds one of svg, mermaid, vega_lite or png_base64, plus an optional alt. Never sent with a Word file, which brings its own.
mentionsobjectId to display name, for mention markup that carries an id instead of a name (Claude’s export does). Required for every such id; a name is at most 200 characters.
sourceobject{ kind, ref, rev, url }, each optional. kind is a short label for where the document came from (lowercase letters, digits and _, up to 64 characters, such as claude_doc); ref the source’s own id (up to 200 characters); rev its revision number, a whole number that turns on the staleness check; url an https link back to it (up to 2,048 characters), shown to you, never on the page.
slugstringRepublish to this link instead of creating one (see Republishing).
forcebooleanRepublish even though source.rev is older than the revision the link holds.
expiresInstringOn create: 1h, 24h, 3d, 7d, 30d or never; left out, the account’s default expiry.
domainstringOn create: the hostname of an active custom domain you own, to serve the link there.
tracking"none"On create: attach no tracking, whatever your Primary tracking option is.
scan_reportstringsummary (the default) or none, which leaves non-blocking PII warnings out of the response. The only option a republish reads.

A republish keeps the link’s expiry, domain and tracking; expiresIn, domain and tracking apply only when the call creates the link.

The response

The link as stored (slug, url, title, a content_hash and content_length of the stored page, and what cleanup changed in changed and transformations), with warnings when the PII scan found something that doesn’t block, and updated_at on a republish. Then a document object.

{
  "slug": "XC5WNjOM",
  "url": "https://htmlvault.io/XC5WNjOM",
  "title": "Q3 plan",
  "content_hash": "f13fc033…",
  "content_length": 5460,
  "changed": false,
  "transformations": [],
  "preview_image": null,
  "document": {
    "figures": { "svg": 0, "mermaid": 1, "vega_lite": 0, "png": 0 },
    "mentions_resolved": 1,
    "adapters": ["claude_export"],
    "source_rev": 2
  }
}
FieldTypeDescription
document.figuresobjectHow many figures of each kind were placed. Images from a Word file count as png.
document.mentions_resolvednumberHow many id-only mentions were replaced by a name from mentions.
document.adaptersstring[]The source adapters that found markup of theirs in the fragment (see Source notes).
document.docxobjectWord input only: { images, images_skipped, tracked_changes_accepted }.
document.source_revnumber | nullThe revision now stored with the link.
document.warningsarrayPresent when something was published with a caveat: unknown_tokens (a figure uses a colour token the theme doesn’t define), images_skipped and tracked_changes_accepted (Word input), manual_edits_replaced (republish).

Figures

Mark each figure in the fragment with a placeholder, <figure data-figure="<id>">, optionally holding a <figcaption>, and send the figure under the same id in figures. An id is letters, digits, - and _, up to 64 characters. The figure is placed in the placeholder and keeps its caption. Every placeholder needs a figure and every figure a placeholder, or the call is refused before anything is built. Claude’s export marks figures its own way, which works as it is (see Source notes).

SVG

{ "svg": "<svg viewBox=\"0 0 640 320\">…</svg>" }. The best choice for anything that isn’t a flowchart-style diagram or a data chart: it stays sharp, follows the page’s light and dark theme when it uses the tokens below, and its text is read by the PII scan.

  • One <svg> root with a viewBox, at most 1 MB.
  • Static drawing only: no scripts, <foreignObject>, event attributes or animation, and every href and url() a #fragment reference within the figure (a marker, a gradient).
  • Well-formed on its own, so a broken figure is named rather than reported at an offset in a page you never saw.
  • Colours as literal values, or var(--doc-…) tokens (below). In a <style> inside the figure, id selectors are rewritten with the figure’s ids (below), but a class or element selector reaches the whole page, so prefer attributes or id selectors.

On placement every id is prefixed per figure (f1-, f2-…) with its references rewritten to match, so two figures’ markers can’t collide, and the <svg> gets role="img". Give it an aria-label, or send alt.

Mermaid

{ "mermaid": "flowchart TD\n A --> B", "alt": "…" }. The page stores the source and draws it in the reader’s browser with Mermaid 12.1.0, served from the same host as the page. The source must start with a diagram type Mermaid knows (flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, mindmap, timeline and the rest), at most 256 KB. Mermaid runs at its strict security level: no HTML in labels and no click handlers, and an %%{init}%% directive can’t lower it. With scripts off, the reader sees the source text.

Vega-Lite

{ "vega_lite": { "mark": "bar", "data": { "values": […] }, … }, "alt": "…" }. Drawn in the reader’s browser with Vega-Lite 6.4.3 (Vega 6.4.0, vega-embed 7.3.0), as SVG, with no export menu. The spec must be valid against that version’s schema, at most 1 MB, and hold its data inline: a url anywhere in it is refused, and the page’s loader refuses every URL besides. Colours come from the theme’s series tokens. With scripts off, the reader sees that the chart could not be displayed, and its caption.

PNG, as a last resort

{ "png_base64": "iVBORw0…", "alt": "…" }, with alt required, at most 1 MB. For a tool that can only give you a picture. It is placed inside an SVG built around it, keeps its own colours in dark mode, and its text can’t be read by the PII scan. Prefer SVG, Mermaid or Vega-Lite.

Colour tokens

A figure can use the theme’s colours, so it follows the reader’s light or dark mode. Claude’s widget names, --cds-…, are aliases of the same values. A figure that uses a token the theme doesn’t define is published, with an unknown_tokens warning.

TokenAliasLightDark
--doc-bg#FFFFFF#0F1117
--doc-text--cds-text-primary#1A1B20#E8E9EC
--doc-text-quiet--cds-text-secondary#5B5F6B#A3A7B1
--doc-line--cds-chart-axis#9AA0AB#6B7180
--doc-rule--cds-chart-grid#E3E5EA#2A2E36
--doc-tint--cds-chart-reference-tint#F4F5F7#161920
--doc-accent#3F5AC4#93A2DE
--doc-good--cds-chart-status-good#047857#3FB984
--doc-warning--cds-chart-status-warning#D97706#E8A33D
--doc-critical--cds-chart-status-critical#DC2626#FF8095
--doc-series-1--cds-chart-categorical-1#3F5AC4#5F7EE6
--doc-series-2--cds-chart-categorical-2#EB6834#D95926
--doc-series-3--cds-chart-categorical-3#1BAF7A#199E70
--doc-series-4--cds-chart-categorical-4#EDA100#C98500
--doc-series-5--cds-chart-categorical-5#E87BA4#D55181
--doc-series-6--cds-chart-categorical-6#008300#008300
--doc-series-7--cds-chart-categorical-7#4A3AA7#9085E9
--doc-series-8--cds-chart-categorical-8#E34948#E66767

Use the series colours in order, starting at 1, one per series. Light series 3, 4 and 5 are pale on white, so a chart using them should label its series.

Word files

A Word (.docx) file is converted on the server into the same fragment and figures, so everything after the conversion is the same. Upload it as it is in a file field of a multipart/form-data body, or send it as docx_base64 in JSON. In a multipart body the other fields are text: source and mentions hold JSON, and force is true or false.

Upload a Word file

curl -X POST https://htmlvault.com/api/v1/links/document \
  -H "Authorization: Bearer hv_..." \
  -F "file=@q3-plan.docx" \
  -F "title=Q3 plan" \
  -F 'source={"kind":"word","ref":"q3-plan","rev":3}'
  • Kept: the Title and Heading 1 to 6 styles, paragraphs, bold, italic, underline and strikethrough, nested lists, tables, links, footnotes and endnotes (as notes at the end), and PNG and JPEG images, which become figures with the image’s description in Word as their alt text.
  • Tracked changes are accepted (insertions kept, deletions dropped), with a tracked_changes_accepted warning.
  • Dropped: comments, headers, footers, page breaks, fonts and colours (the theme decides the look), and text boxes, shapes, SmartArt and charts, unless Word stored a picture of them, which becomes a figure. Macros and embedded objects are never read. Document properties (author, company, last editor) never reach the page.
  • EMF and WMF images, and any image over 1 MB, can’t be shown: a note marks each place, with an images_skipped warning.

Limits: at most 4 MB as uploaded, 50 MB uncompressed, 1,000 entries in the file, and no entry expanding more than 100 times; past any of them the answer is docx_too_large. A file that isn’t a readable .docx (not a zip, password-protected, or with no document in it) is invalid_docx. Images count towards the 5 MB page limit, so a file full of photos reaches it first. Diagrams that arrive as pictures can’t be PII-scanned and don’t follow dark mode; a fragment with SVG figures avoids both.

Republishing

Send the document again with the link’s slug and the page is rebuilt and written to the same link, so the URL you shared stays the same. Only a link this endpoint created can be republished; any other is not_a_document. The link must be one you may edit: yours, or, with ?scope=all and the links.edit_any permission, another member’s in your organization.

  • When the link and the call both carry a source.ref, they must match, or the answer is source_mismatch with the link’s stored_ref.
  • When both carry a revision, a source.rev lower than the link’s is stale_source (with stored_rev) unless you send force: true. An equal revision is accepted, so republishing the same revision picks up a new theme.
  • Parts of source you leave out keep their stored values; rev is always what the call sends.
  • The last write wins, as with PATCH /api/v1/links/{slug}. If the page was edited another way since it was last published here, the edits are replaced and the response warns manual_edits_replaced.

GET /api/v1/links/{slug} returns the link’s source (null for a link that isn’t a document), so you can compare the stored revision with your source’s before republishing.

Source notes

Any tool that writes HTML or saves Word files can be a source. Some exports carry markup of their own, which an adapter handles when the fragment contains it; the response lists those that ran in document.adapters.

Claude docs

Export the doc as HTML and send it as html (or html_base64), with source.kind claude_doc and the doc’s id as source.ref. The claude_export adapter reads its markup:

  • Each embedded widget, <figure data-embed="node/<id>">, is a placeholder keyed by that id: send the widget’s figure under it.
  • A mention carries an account id instead of a name: send mentions, id to display name, for every one. The id itself is removed from the page in every case.
  • A date chip reads as “October 3, 2026” and keeps its <time datetime>; a checklist item is drawn as a checkbox, ticked or not.

To turn a widget into an SVG figure:

  • Read the widget’s JSX module and evaluate its constants, helpers and .map() calls into literal elements.
  • Rename JSX attributes to SVG ones: strokeWidth to stroke-width, markerEnd to marker-end, textAnchor to text-anchor, fontWeight to font-weight, fillOpacity to fill-opacity.
  • Keep var(--cds-…) colours as written: the theme defines them.
  • Draw a chart widget from its data rows, as an SVG or as a Vega-Lite spec.
  • When none of that is possible, a screenshot of the widget can go in as png_base64.

Or download the doc as Word and upload that: no figure work at all, with figures as pictures.

ChatGPT

ChatGPT previews Mermaid and Vega-Lite code blocks: send them as mermaid and vega_lite figures and they are drawn the same way on the page. A writing block downloaded as Word can be uploaded as it is.

Google Docs and Word

Download the document as .docx (in Google Docs, File, Download, Microsoft Word) and upload it. See Word files for what comes across.

Your own HTML

Write the body as a fragment and mark figures with data-figure. Everything that isn’t a placeholder is published as written, then cleaned the way every HTMLvault page is. A list of <input type="checkbox"> items is styled as a checklist, and wide tables scroll sideways on a phone instead of the page.

Errors

Each refusal is { "error": "...", "message": "..." }, with the fields named below. The document’s own checks run first, so a bad figure or mention is named before anything is published.

FieldTypeDescription
missing_key, invalid_key401No API key, or one that isn’t recognized or was revoked.
plan_required403The key belongs to a Free account.
invalid_scope, forbidden_scope400, 403On a republish, scope is neither me nor all, or all without the permission.
invalid_input400A field has the wrong shape: none or two of the inputs, no title, figures or mentions not an object.
invalid_source400source breaks one of its limits.
invalid_expiry400expiresIn isn’t one of the choices.
unknown_domain400domain isn’t an active domain you own.
invalid_json, invalid_form400The body can’t be read; invalid_form also when a multipart field is sent twice, or a file comes with another input.
not_a_fragment422The html holds <html>, <head> or <body>.
invalid_base64422html_base64 or docx_base64 doesn’t decode, or the html isn’t valid UTF-8.
invalid_docx422The Word file isn’t a readable .docx.
missing_figures422A placeholder has no figure; lists the ids.
unknown_figures422figures names an id with no placeholder, or is sent with a Word file (400 in a multipart body).
invalid_figure422A figure breaks one of its rules; names the id and the rule.
unresolved_mention422An id-only mention isn’t in mentions; lists the ids.
malformed_html422The fragment isn’t well-formed, with the same reasons as every publish (deeply nested Word tables can reach too_deeply_nested).
docx_too_large, document_too_large413The Word file passes a limit, or the page would be over 5 MB.
not_found404slug names no link you can edit.
link_is_draft, not_a_document409slug names an unfinished draft, or a link this endpoint didn’t create.
source_mismatch, stale_source409See Republishing.

The publish checks every link gets answer as they do on POST /api/v1/links: blocked_by_policy (422, your organization’s PII rules), unsafe_content, too_many_urls and unverifiable_content (422), rate_limited (429), and safety_check_unavailable and policy_unavailable (503, with Retry-After). Calls share your key’s limit of 100 requests a minute.

What the page is checked for

The built page goes through the same create and edit code as any other link, so it is scanned for PII and secrets, its links are checked against Google’s Safe Browsing lists, and your organization’s publishing rules apply. Diagram labels and chart data are text in the page, so the scan reads them; text inside a picture it can’t. The theme loads no fonts and makes no third-party request: the Mermaid and Vega-Lite renderers are served from the page’s own host, only on pages that use them. See the Domains page for where links are served.