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.
| Plan | Largest file |
|---|---|
| Free (the upload form only) | 5 MB |
| Pro and Teams | 15 MB |
| Enterprise | 25 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 }'| Field | Type | Description |
|---|---|---|
content_typeRequired | string | image/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. |
bytes | number | The 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 …"
}| Field | Type | Description |
|---|---|---|
src | string | The path to write in your page once the upload is complete. |
upload.url | string | Where to PUT the file. It works for 15 minutes, until upload.expires_at. |
upload.headers | object | The headers the PUT must carry, exactly: the Content-Type you asked for. |
upload.max_bytes | number | Your plan’s largest file, in bytes. |
complete_url | string | Where to POST once the file is uploaded. |
curl, next | string | The 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.
| Field | Type | Description |
|---|---|---|
status | string | staged (waiting for the file), ready or rejected. |
src | string | The path to use in a page. |
width, height | number | null | The stored copy’s size in pixels, once ready. |
format, content_type | string | null | png, jpeg or webp, and its media type, once ready. |
bytes | number | null | The stored copy’s size, once ready. |
created_at, ready_at | string | When the slot was made, and when the upload was completed. |
rejected_reason | string | null | Why 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, withog:image:widthandog: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>, asrcsetentry, anhref, anog:imagecontent, a CSSurl()) 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>andog:imagetags that show the image. Asrcsetentry, CSSurl()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.
| Field | Type | Description |
|---|---|---|
missing_key, invalid_key | 401 | No API key, or one that isn’t recognized or was revoked. |
plan_required | 403 | The key belongs to a Free account. |
invalid_json | 400 | The body of POST /api/v1/assets isn’t JSON. |
invalid_content_type | 400 | content_type isn’t one of the five formats (SVG included). |
invalid_bytes | 400 | bytes isn’t a positive whole number. |
too_large | 413 | The file, as declared or as uploaded, is over your plan’s limit, given in limit (bytes). |
rate_limited | 429 | Your 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_uploads | 429 | The account has 100 uploads waiting to be completed. Complete them, or wait for their upload URLs to expire. |
not_found | 404 | No image with that id on this account. |
not_uploaded | 409 | Completion was asked for before anything was PUT to the upload URL. |
storage_full | 409 | The account holds as many hosted images as its plan allows. Images are freed as the pages that use them expire or are deleted. |
storage_error | 502 | The image store didn’t answer. Retry in a minute. |
assets_unavailable | 503 | Hosted images aren’t available just now; from a publish, with Retry-After. |
unknown_asset | 422 | From 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.