Media API
The Media API covers serving images with on-the-fly CDN transformations. For uploading files and the GraphQL File type, query surface, and lifecycle mutations, see Files API — this page does not restate them.
Uploading
Two paths upload into the media library:
- GraphQL (recommended) — the two-step
createFileUpload→ PUT bytes →confirmFileUploadflow. Binary bytes never touch the GraphQL endpoint. See Files API. - REST multipart — a single
POSTfor small files that hides the three-step dance:
POST https://api.foir.dev/api/files/uploadcurl -X POST https://api.foir.dev/api/files/upload \
-H "x-api-key: sk_your_secret_key" \
-F "file=@/path/to/image.jpg" \
-F "folder=campaigns/spring-26"async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.foir.dev/api/files/upload", {
method: "POST",
headers: {
"x-api-key": process.env.FOIR_API_KEY,
},
body: formData,
});
return response.json(); // → a File object (see Files API)
}The response is the canonical File object — id, filename, mimeType, size, url, width, height, blurhash, alt, createdAt. There is no separate processing “status”: for images, width, height, and blurhash are populated synchronously during the upload.
Limits
- Maximum file size: 10 MB (the REST path is intended for small files; for large media use the GraphQL pre-signed flow).
- Authentication: an API key with the
files:writescope.
Supported file types
The REST endpoint validates the declared content type against the file’s magic bytes and accepts:
| Category | Formats |
|---|---|
| Images | JPEG, PNG, GIF, WebP, AVIF, SVG |
| Video | MP4, WebM, MOV |
| Audio | MP3, WAV, OGG |
| Documents | PDF, JSON, CSV, XML |
| Fonts | WOFF, WOFF2 |
| Archives | ZIP |
Image Transformations
Image URLs include an opaque ?t= token the platform mints at resolution time. The token encodes the size preset and any per-usage crop/focal data. Free-form ?width=…&fit=… query parameters are not honoured — only the platform’s own tokens trigger transforms.
URL format
https://cdn.foir.dev/{fileId}?t=eyJzIjoibWVkaXVtIn0The ?t= value is a base64url-encoded JSON object:
{ "s": "medium", "c": "10,10,80,80", "f": "0.5,0.4" }| Field | Meaning | Format |
|---|---|---|
s | Size preset (long-edge width in px) | thumbnail (160) / small (640) / medium (1024) / large (1280) / xlarge (1920) / 2xl (2560) / 3xl (3840) / 4xl (5120) |
c | Crop rectangle | x,y,width,height as 0–100 percentages |
f | Focal point | x,y as 0–1 floats |
All three fields are optional. A token with only s resizes to a fixed long-edge width; adding c applies a per-usage crop before resize; f controls smart-crop fallback.
Format negotiation
Output format follows the Accept header. Browsers that send Accept: image/avif get AVIF; everyone else gets the best supported format. There’s no format parameter on the URL.
Why opaque tokens
Free-form transform parameters would let scrapers synthesise arbitrary variants and burn through CDN-side image-resize bills. The token grammar is opaque enough that only URLs the platform emits hit the transform pipeline; unknown query strings return the original image.
Resolved image values
The platform emits transform URLs automatically when you resolve image fields. Don’t construct ?t= tokens client-side — let the API response carry them. An image field resolves to an ImageValue:
query {
product(naturalKey: "blue-widget") {
hero { # ImageValue
url # https://cdn.foir.dev/file_abc?t=… (default tier)
width
height
alt
blurhash
dominantColor
variants(format: WEBP) { # responsive tiers the platform pre-emits
thumbnail { size format url width height }
small { size format url width height }
medium { size format url width height }
large { size format url width height }
xlarge { size format url width height }
}
}
}
}Each tier is an ImageVariant object — not a bare URL string — so it requires a sub-selection. Its fields are size (the tier name), format (the rendered format), url, and the true rendered width/height (capped at the source and, for cropped images, derived from the crop aspect — so you can render without layout shift).
The variants(format: …) field takes an optional format argument — one of WEBP (the default), AVIF, JPEG, or PNG — that selects the format for the whole tier set. Each tier is a distinct, CDN-cacheable URL per format, so to drive a <picture> query the set once per format using aliases:
hero {
webp: variants(format: WEBP) { large { url width height } }
avif: variants(format: AVIF) { large { url width height } }
}The variants object also exposes the larger tiers under _2xl, _3xl, and _4xl (a GraphQL identifier can’t start with a digit, hence the leading underscore).
<picture>
<source srcset="https://cdn.foir.dev/file_abc?t=eyJzIjoibGFyZ2UifQ" media="(min-width: 1024px)" />
<source srcset="https://cdn.foir.dev/file_abc?t=eyJzIjoibWVkaXVtIn0" media="(min-width: 640px)" />
<img src="https://cdn.foir.dev/file_abc?t=eyJzIjoic21hbGwifQ" alt="..." loading="lazy" />
</picture>Blurhash placeholders
Resolved image values carry a blurhash field — a compact string encoding of a blurred placeholder. Use it to display a low-resolution preview while the full image loads.
import { Blurhash } from "react-blurhash";
import { useState } from "react";
function ImageWithPlaceholder({ image }) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: "relative" }}>
{!loaded && image.blurhash && (
<Blurhash hash={image.blurhash} width={400} height={300} />
)}
<img
src={image.url}
alt={image.alt}
onLoad={() => setLoaded(true)}
style={{ display: loaded ? "block" : "none" }}
/>
</div>
);
}Related
- Files API — uploads, the
Filetype, listing, and lifecycle mutations (deleteFile,restoreFile,permanentlyDeleteFile). - Media & Files — feature overview.