Every site gets a media library backed by a shared CDN. You upload an image, PDF, video, or web font once; StaticOwl stores it, hands you a permanent CDN URL, and you drop that URL into a content field or a template. Uploads are content-addressed — identical bytes de-duplicate to the same asset — and served with a long, immutable cache so the CDN does the heavy lifting.
This guide covers uploading (from a file or a URL), how an upload becomes a stored asset, image transforms, and how to reference media in content and templates.
Quick start
Two ways to get bytes into your library:
| You have… | Use |
|---|---|
| A local file on your computer | POST /api/media/upload (multipart), or the dashboard Media → Upload |
| A public URL (generated image, stock photo, another site) | POST /api/media/upload-from-url |
| An agent that produced an image URL | MCP media_upload_from_url |
| Local files, but you're driving via an AI agent | MCP create_upload_link → hand the user a browser upload page |
Both upload paths return the same JSON, the important field being url — the
permanent CDN address you store and render:
{
"ok": true,
"id": "asset:9f2a1c4b7e0d3a56",
"url": "https://media.preview.staticowl.com/_media/site_mysite/9f2a1c4b…d3a56.jpg",
"filename": "hero.jpg",
"mimetype": "image/jpeg",
"bytes": 184213,
"width": 1600,
"height": 900,
"sha256": "9f2a1c4b…",
"variants": []
}
Uploading
From a local file (multipart)
The upload field name is file:
curl -X POST https://app.staticowl.com/api/media/upload \
-H "Authorization: Bearer $STATICOWL_API_KEY" \
-H "X-Site-Id: site:mysite" \
-F "file=@./hero.jpg"
From a URL (server-side fetch)
Pass any public http(s) URL and StaticOwl fetches the bytes on the server,
then runs the exact same pipeline as a multipart upload:
curl -X POST https://app.staticowl.com/api/media/upload-from-url \
-H "Authorization: Bearer $STATICOWL_API_KEY" \
-H "X-Site-Id: site:mysite" \
-H "Content-Type: application/json" \
-d '{
"sourceUrl": "https://replicate.delivery/…/out.png",
"filename": "generated-hero.png",
"altText": "A barn owl mid-flight at dusk"
}'
Why from-URL matters. Because the fetch happens server-side, it bypasses two things that block a browser:
- CORS — a browser can't read cross-origin image bytes to re-upload them; the server just fetches them.
- Hotlink blocks — many hosts (WordPress installs, stock CDNs) refuse
browser hotlinking but happily serve a plain server request.
upload-from-urlpulls the file in and re-hosts it on your own CDN, so the reference never breaks later.
This is the canonical path for agents: an AI that generates an image with
its own tool (Replicate, DALL·E, Ideogram, a self-hosted diffusion model) gets
back a URL, not bytes — so it passes that URL straight to
upload-from-url/media_upload_from_url and stores the returned CDN URL.
upload-from-url also accepts a Google Drive share link to a single file
(shared as "Anyone with the link") and pulls it in directly. Drive folder
links are not supported yet — they return a gdrive_folder_unsupported 400.
Listing, fetching, deleting
# List assets (newest first). q= filters filename/url; limit caps the page (max 500).
curl "https://app.staticowl.com/api/media/?limit=30&q=hero" \
-H "Authorization: Bearer $STATICOWL_API_KEY" -H "X-Site-Id: site:mysite"
# Delete an asset — removes the Asset node AND the S3 object. Not reversible.
curl -X DELETE "https://app.staticowl.com/api/media/asset:9f2a1c4b7e0d3a56" \
-H "Authorization: Bearer $STATICOWL_API_KEY" -H "X-Site-Id: site:mysite"
MCP (AI agents)
| Tool | Required args | What it does |
|---|---|---|
media_upload_from_url |
sourceUrl |
Fetch a public URL (or single-file Drive link) into media. Optional filename, altText. 25 MB max. |
media_list |
— | List assets, newest first. Optional search, limit (default 30, max 500), full. |
media_get |
id |
One asset by id, including its variant list. |
media_delete |
id |
Remove the Asset node and the S3 object. Not reversible. |
media_stock_search |
query |
Search Unsplash / Pexels / Pixabay; returns URLs you can pass to media_upload_from_url. Optional provider, limit. |
media_generate_alt_text |
imageUrl |
Claude-vision alt text for an image URL. Needs ANTHROPIC_API_KEY on the server. |
create_upload_link |
— | Mint a one-time, single-site browser upload page for local files (~24h). Hand it to the user; MCP can't receive local file bytes. |
check_upload |
— | List the most recent uploads (default 5, max 30) — use right after a create_upload_link hand-off. |
There is no multipart MCP upload tool by design: an agent can't stream local
file bytes over MCP. For a URL the agent has, use media_upload_from_url; for
files on the user's disk, use create_upload_link and then check_upload.
Example agent flow:
media_stock_search { query: "misty pine forest at dawn", provider: "unsplash", limit: 5 }
media_upload_from_url { sourceUrl: "https://images.unsplash.com/photo-…", altText: "Fog over a pine forest" }
# → returns { id, url, … }; store url in your content field.
The media CDN & content-addressed dedup
Every upload becomes an object in S3 and is served from the shared media CDN. The moving parts:
| Piece | Value |
|---|---|
| CDN host | media.preview.staticowl.com (env STATICOWL_MEDIA_HOST) |
| S3 key format | _media/<sitePrefix>/<sha256><ext> |
<sitePrefix> |
your site id with non-alphanumeric chars replaced by _ (e.g. site:mysite → site_mysite) |
| Public URL | https://media.preview.staticowl.com/<key> |
| Cache-Control | public, max-age=31536000, immutable (one year) |
Content addressing. The upload's SHA-256 hash is the filename. Two consequences:
- Identical bytes de-duplicate. Upload the same image twice — same hash, same key, same asset. The second upload is effectively free and idempotent.
- Different bytes get a different URL. Edit an image and re-upload and you
get a brand-new URL. That's why the CDN can cache
immutablefor a year — a given URL's bytes never change. Update the content field to point at the new URL.
The Asset record
Each upload upserts an Asset node on your site's graph, linked
(:Site)-[:HAS_ASSET]->(:Asset). Its id is asset:<first 16 hex of the sha256>.
Fields:
| Field | Meaning |
|---|---|
id |
asset:<hash16> |
url |
the CDN URL you reference |
key |
the S3 key |
sha256 |
full content hash |
mimetype |
e.g. image/jpeg, application/pdf |
bytes |
size of the primary object |
width / height |
intrinsic pixel dimensions (images/SVG; null for video/PDF) |
filename |
original name |
altText |
optional, saved from the upload or media_generate_alt_text |
variants |
JSON array of derived renditions (see below; empty unless auto-optimize is on) |
uploadedBy / uploadedAt |
provenance |
Image transforms & variants
If a site enables auto-optimize (mediaSettings.autoOptimize.enabled, off by
default, toggled per-site), image uploads run through a sharp
pipeline that resizes, strips EXIF (privacy + bytes), auto-rotates from the EXIF
orientation flag, and produces a set of variants alongside the primary:
| Variant | What it is |
|---|---|
primary |
Re-encoded source, resized to ≤ maxWidth (default 2400px). JPEG at jpegQuality (default 85); PNGs stay PNG to keep transparency. Replaces the canonical key. |
webp |
Same dimensions as primary, WebP q82 — only when generateWebP is on. |
thumb400 |
400px-wide JPEG q80, for list/nav thumbnails. |
thumb800 |
800px-wide JPEG q80, for cards. |
Variants are stored under sibling S3 keys derived from the same hash
(…<hash>_t400.jpg, …_t800.jpg, …<hash>.webp) and recorded in the Asset's
variants array as {name, key, url, mimetype, bytes, width, height}. Read
them with media_get.
Notes:
- Only raster images go through the pipeline. SVG, video, and PDF pass through untouched (SVG dimensions are still read from its XML).
- Formats: sharp reads JPEG, PNG, WebP, AVIF, GIF, TIFF. The primary re-encodes to JPEG or PNG; the optional extra rendition is WebP.
- Failure is safe: if the transform throws (corrupt or unsupported input),
the route falls back to storing the raw bytes — you never lose an upload
because it couldn't be shrunk. When auto-optimize is off, every upload takes
the raw path and
variantsis[].
Referencing media in content
A media reference is just its CDN URL string. Give your content type an
image field and store the url you got back from the upload:
types_create {
name: "product",
fields: [
{ name: "title", type: "string", required: true },
{ name: "image", type: "image" }
]
}
content_create {
type: "product",
fields: {
title: "Kenya AB",
image: "https://media.preview.staticowl.com/_media/site_mysite/9f2a…d3a56.jpg"
}
}
In the dashboard editor an image field shows a text input plus a Pick
button that opens the media library so you don't have to paste URLs by hand.
Referencing media in templates
The stored value is a plain URL, so the simplest reference is a bare expression — autoescaped, which is safe inside an attribute:
<img src="{{ image }}" alt="{{ title }}">
Better: the built-in img filter emits a complete responsive <img> tag,
so you don't hand-write attributes:
{{ image | img: alt: title, width: 1600, height: 900 }}
img accepts these named args (a bare first arg is treated as alt):
| Arg | Effect |
|---|---|
alt |
Alt text (also title). |
width / height |
Sets the attributes (helps layout stability). |
loading |
lazy (the default) or eager for above-the-fold hero images. |
class / style |
Extra attributes on the tag. |
Inside a {% list %} loop the URL lives on the loop variable, same as any field:
{% list type:"product" order:"title" as p %}
<a href="/products/{{ p.slug }}.html">
{{ p.image | img: alt: p.title, width: 400 }}
<h3>{{ p.title }}</h3>
</a>
{% endlist %}
Self-hosted web fonts work the same way — upload the .woff2, then reference
its CDN URL in an @font-face src. Cross-origin font loading is already
handled at the CDN (see gotchas).
Formats & size limits
| Max file size | 25 MB — both multipart upload and upload-from-url (fetched-size cap) |
| Images | JPEG, PNG, GIF, WebP, AVIF, SVG |
| Video | MP4, WebM, MOV |
| Documents | |
| Web fonts | WOFF2, WOFF, TTF, OTF (accepted by extension even with a generic mimetype) |
upload-from-url additionally requires the source to be http:// or https://
(no file:// / data://), fetches with a 15-second timeout (20s for Drive),
and rejects anything over the 25 MB cap with a 413.
Gotchas & tips
- Immutable caching = new bytes, new URL. Objects are served
max-age=31536000, immutable. You can't "replace" an image at the same URL — edit the image, re-upload (you'll get a fresh hash-based URL), and update the field. This is a feature: references never go stale under the cache. - Dedup is silent and free. Re-uploading identical bytes returns the same
asset:id and URL. Safe to run an import twice. upload-from-urlbeats browser re-hosting. When migrating from another platform, pull assets server-side — it sidesteps CORS and hotlink blocks that would fail a browser fetch, and re-hosts everything on your own CDN.- Font CORS is already handled. The media CDN answers cross-origin font
requests with the right CORS headers, so
@font-faceagainst a self-hostedmedia.preview.staticowl.comURL just works — no per-site setup. - Deletes are permanent and remove the S3 object.
media_delete/DELETE /api/media/:iddrops both the Asset node and the underlying object. Edge caches may keep serving the URL until their TTL drains, but the origin is gone — don't delete an asset a live page still references. - Variants only exist with auto-optimize on. By default
variantsis[]and the primary is your original bytes. Turn onautoOptimizeper-site (viaPUT /api/sites/:id) if you want WebP + thumbnails generated on upload. - Google Drive: single files shared "Anyone with the link" only; folder links return a clear 400.
See also
- Content modeling — field types, including the
imagemedia field. - Template reference — the
imgfilter,{% list %}, and the rest of the Liquid surface. - Zero to deployed — the 5-minute quickstart, which
uses
media_upload_from_urlto fill an image field.