Hosted images

Upload an image once and use it in the pages you publish, as /_a/<id>. HTMLvault strips its metadata, stores it, and serves it from the page’s own address, only while a page that uses it is open to readers. Upload images with the REST API on Pro, Teams or Enterprise, or drop them on the upload form when you’re signed in, Free included.

How hosted images work

An image you upload gets an id and a path, /_a/<id>. Write that path in a page (an <img src>, a srcset entry, a CSS url(), or the page’s og:image) and publish the page as usual. The path has no host, so it resolves to whichever host serves the page: htmlvault.io, your team’s subdomain on hvlt.io, or your own domain. A page on your own domain never names an HTMLvault host.

  • There is no standalone image URL. /_a/<id> answers only on a host that serves a page using the image, and only while that page is open to readers: not while it is a draft, and not once it has expired, been deleted or been taken down. On htmlvault.com it is a 404, as published pages are.
  • Behind a password, the image loads only for a reader who has entered the password.
  • Other sites can’t show it: every answer carries Cross-Origin-Resource-Policy: same-origin.
  • A public image may be cached for up to an hour, so a password added later or a shortened expiry reaches it within the hour. A take-down clears the cache at once.
  • Loading an image is not a view, and nothing is billed for it.
  • An image belongs to the account that uploaded it (an organization’s API key uploads as the organization’s owner). A page may use your own images, and keep any image already in the version it replaces, so editing a link shared with you keeps the images its owner put there. A page published without an account can use none.
  • One page may use at most 200 different hosted images.

Formats and limits

PNG, JPEG, WebP, GIF and AVIF. Never SVG: put the <svg> element in the page itself, where it already works and stays sharp.

PlanLargest file
Free (the upload form only)5 MB
Pro and Teams15 MB
Enterprise25 MB

The limit follows the plan the account is on, which for a member of a Teams or Enterprise organization is the organization’s. The API needs Pro, Teams or Enterprise, so a Free account uploads from the upload form. Each upload is decoded and encoded again before it is stored, never kept as it arrived:

  • Metadata is dropped: EXIF, GPS location, XMP and comments. The camera’s orientation is applied first, so the image stays the right way up, and colours are converted to sRGB.
  • The long edge is capped at 2,560 pixels. Nothing is enlarged or cropped.
  • A still image is stored as PNG when it really uses transparency, otherwise as the smaller of JPEG and PNG. An animated GIF or WebP is stored as an animated WebP.
  • An image over 50 megapixels, or an animation over 300 frames, is refused.

The stored copy’s format, bytes, width and height come back in the asset, so a page can give its <img> the right size.

Upload with the API

Three calls with an API key (Authorization: Bearer hv_…, Pro, Teams or Enterprise). The file goes straight to storage on the second, so no request-size limit of ours applies to it and it never passes through a model’s context.

1. Ask for an upload slot

curl -X POST https://htmlvault.com/api/v1/assets \
  -H "Authorization: Bearer hv_..." \
  -H "Content-Type: application/json" \
  -d '{ "content_type": "image/png", "bytes": 48213 }'
FieldTypeDescription
content_typeRequiredstringimage/png, image/jpeg, image/webp, image/gif or image/avif: the Content-Type you will send the file with. It is signed into the upload URL.
bytesnumberThe file’s size, checked against your plan’s limit before anything is written. The real size is checked again on completion.

The answer (201):

{
  "asset": {
    "id": "4fQk2ZpX9LmA7bRt1VcW8e",
    "status": "staged",
    "src": "/_a/4fQk2ZpX9LmA7bRt1VcW8e",
    …
  },
  "src": "/_a/4fQk2ZpX9LmA7bRt1VcW8e",
  "upload": {
    "url": "https://…r2.cloudflarestorage.com/…",
    "method": "PUT",
    "headers": { "Content-Type": "image/png" },
    "expires_at": "2026-10-28T17:15:00.000Z",
    "max_bytes": 15728640
  },
  "complete_url": "https://htmlvault.com/api/v1/assets/4fQk2ZpX9LmA7bRt1VcW8e/complete",
  "curl": "curl --fail -X PUT -H 'Content-Type: image/png' --upload-file ./image '…'",
  "next": "PUT the file to upload.url …"
}
FieldTypeDescription
srcstringThe path to write in your page once the upload is complete.
upload.urlstringWhere to PUT the file. It works for 15 minutes, until upload.expires_at.
upload.headersobjectThe headers the PUT must carry, exactly: the Content-Type you asked for.
upload.max_bytesnumberYour plan’s largest file, in bytes.
complete_urlstringWhere to POST once the file is uploaded.
curl, nextstringThe PUT as a shell command (with ./image for the file’s path), and the remaining steps in a sentence, for an agent to follow.

2. Upload the file

PUT the file to upload.url with exactly the headers in upload.headers, before upload.expires_at. Send no Authorization header: the URL is signed.

curl --fail -X PUT \
  -H 'Content-Type: image/png' \
  --upload-file ./chart.png \
  '<upload.url>'

3. Complete the upload

POST complete_url with the same Authorization header and no body. HTMLvault reads the file back, checks it and encodes it again (see Formats and limits), and answers the asset: ready (200), or rejected (422) with a rejected_reason. Calling it again is safe: a finished upload is answered as it is.

curl -X POST https://htmlvault.com/api/v1/assets/4fQk2ZpX9LmA7bRt1VcW8e/complete \
  -H "Authorization: Bearer hv_..."
{
  "asset": {
    "id": "4fQk2ZpX9LmA7bRt1VcW8e",
    "status": "ready",
    "src": "/_a/4fQk2ZpX9LmA7bRt1VcW8e",
    "width": 1600,
    "height": 900,
    "format": "png",
    "content_type": "image/png",
    "bytes": 41877,
    "created_at": "2026-10-28T17:00:00.000Z",
    "ready_at": "2026-10-28T17:00:04.000Z",
    "rejected_reason": null
  }
}

A rejected_reason starts with a code, then says what was wrong: unreadable, unsupported_format, svg_not_supported, too_many_pixels, too_many_frames or empty. A file over your plan’s limit is answered 413 too_large instead. Upload the file again to a new slot. A rejected upload is never served and is deleted a day later.

Look an image up

GET /api/v1/assets/<id> answers { "asset": { … } } for one of your account’s images, and 404 for any other id.

FieldTypeDescription
statusstringstaged (waiting for the file), ready or rejected.
srcstringThe path to use in a page.
width, heightnumber | nullThe stored copy’s size in pixels, once ready.
format, content_typestring | nullpng, jpeg or webp, and its media type, once ready.
bytesnumber | nullThe stored copy’s size, once ready.
created_at, ready_atstringWhen the slot was made, and when the upload was completed.
rejected_reasonstring | nullWhy the upload was rejected.

Use it in a page

Write the src anywhere the page names an image, and publish or update the page with any HTMLvault call: POST /api/v1/links, PATCH /api/v1/links/{slug}, the batch, draft and document endpoints, or the website.

<img src="/_a/4fQk2ZpX9LmA7bRt1VcW8e" alt="Quarterly revenue by region" width="1600" height="900">

<img srcset="/_a/Ab3dE5fG7hJ9kL1mN3pQ5r 800w, /_a/4fQk2ZpX9LmA7bRt1VcW8e 1600w"
     sizes="(max-width: 800px) 100vw, 800px" alt="Quarterly revenue by region">

<style>
  .hero { background-image: url(/_a/4fQk2ZpX9LmA7bRt1VcW8e); }
</style>

Write the path without a host. Other hosts’ pages can’t show the image, so an address that names htmlvault.io breaks once the link is served from your own domain.

Every publish checks the images the page names. If one doesn’t exist, hasn’t finished uploading, or belongs to another account, nothing is published: the answer is 422 unknown_asset, with assets listing each id and its problem (not_found, not_ready, not_yours or anonymous). If the check can’t run, the answer is 503 assets_unavailable with a Retry-After of 30 seconds.

Use it as the preview image

Name a hosted image as the page’s og:image and it becomes the link’s preview image where the link is shared (Slack, LinkedIn, iMessage and the rest):

<meta property="og:image" content="/_a/4fQk2ZpX9LmA7bRt1VcW8e">
  • The value is exactly /_a/<id>, with no host, query or fragment.
  • The page’s head names https://<the host serving the page>/_a/<id>/og: the same image at most 1,200 pixels wide, as JPEG or PNG (the first frame of an animation), never cropped or enlarged, with og:image:width and og:image:height.
  • Preview images show for links on Pro, Teams and Enterprise. On Free, and for links published without an account, the preview is a text card.
  • Behind a password, or once the link has expired, the preview shows no image from the page.

When images are deleted

There is no delete call. An image is deleted once no page uses it:

  • An upload never completed, and a rejected one, is deleted a day after its slot was made.
  • A ready image that no link uses is deleted once 7 days have passed since both its upload and the last publish that used it.
  • A link stops using an image when its page is edited to drop it, when the link is deleted, and when its content is deleted after it expires (your data-retention setting decides when).

So an image uploaded and never published needs no cleaning up, and one you replace on a page goes a week later.

On the website

The upload form

Signed in on any plan, drop images on the upload form with the HTML file, or pick them with Browse. A page saved together with its images can be dropped all at once. Each image is uploaded as soon as it is added.

  • References in the HTML to the file’s name (an <img src>, a srcset entry, an href, an og:image content, a CSS url()) are pointed at the hosted copy, so a page exported with its images publishes as it looked.
  • An image the HTML doesn’t name yet is listed as Not in your HTML yet: Insert puts an <img> tag at the cursor in the HTML editor, and Copy tag copies it.
  • The preview shows the files you dropped, and Create link waits until every image has uploaded.
  • Without an account the form asks you to sign up first: a page published without an account can’t use hosted images.

Editing a link

Editing a link opens the same form with the images its page already uses. While the HTML editor is open:

  • Add images uploads more, as above.
  • Replace uploads a new file that takes over every reference to the old one.
  • Remove from page deletes the <img> and og:image tags that show the image. A srcset entry, CSS url() or link that names it is left in place, and the form says how many remain.

Neither deletes the image itself: it goes a week after no page uses it.

The links dashboard

Each link’s Images tab on the links dashboard lists the hosted images its page uses, with each one’s size and /_a/ path.

Errors

Each refusal is { "error": "...", "message": "..." }, with the fields named below.

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_json400The body of POST /api/v1/assets isn’t JSON.
invalid_content_type400content_type isn’t one of the five formats (SVG included).
invalid_bytes400bytes isn’t a positive whole number.
too_large413The file, as declared or as uploaded, is over your plan’s limit, given in limit (bytes).
rate_limited429Your key’s requests a minute are spent, or the account has started as many uploads as it can today (counted from midnight Pacific time). With Retry-After.
too_many_open_uploads429The account has 100 uploads waiting to be completed. Complete them, or wait for their upload URLs to expire.
not_found404No image with that id on this account.
not_uploaded409Completion was asked for before anything was PUT to the upload URL.
storage_full409The account holds as many hosted images as its plan allows. Images are freed as the pages that use them expire or are deleted.
storage_error502The image store didn’t answer. Retry in a minute.
assets_unavailable503Hosted images aren’t available just now; from a publish, with Retry-After.
unknown_asset422From a publish: the page names an image it can’t use (see Use it in a page).

The upload calls share your key’s limit of 100 requests a minute with every other API call.