Media
Version: 1.0
Base URL: /api/v1Authentication: Not required for public media serving
For uploading and managing media (admin), see the Media Admin API.
Overview
The public Media endpoint serves media assets — images, documents, and videos — over a fast, CDN-cached URL, with optional on-the-fly image transformations. Serving requires no authentication; isolation relies on the URL being unguessable (UUID filenames), not on server-side validation of the project ID against the requester. See Security Considerations for details.
Key Features:
- Serve media publicly with image transformations
- Cloudflare R2 storage backend
- Cloudflare Image Resizing integration
- 1-year immutable cache for optimal performance
To upload, organise, update, or delete media (all of which require an authenticated admin session), see the Media Admin API.
Table of Contents
Authentication
The public media serving endpoint (GET /media/:projectId/:filename) requires no authentication — anyone with the URL can retrieve the file. The project ID embedded in the URL is used as-is to build the tenant-prefixed R2 key, with no cross-check against an authenticated session or API key; isolation instead relies on UUID-based filenames being unguessable, which prevents URL enumeration. See Security Considerations for details.
For the rest of the public content API (fetching pages, collections, and their resolved media), requests are authenticated with an API key. See the Public REST API for details.
Admin media operations (upload, update, move, delete) use session-cookie authentication and are documented separately in the Media Admin API.
Public Endpoints
GET /media/:projectId/:filename
Serve a media file publicly with optional image transformations.
Authentication: Not required Rate Limit: no per-key in-Worker limit — this endpoint is unauthenticated, so there is no API key to key a limit on. It is covered only by the coarse per-IP Cloudflare WAF backstop (live since 2026-07-13); Cloudflare's CDN cache further reduces how often requests reach the origin. Figures: Limits & Errors. This matches the per-endpoint breakdown under Rate limiting below.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
projectId | string | Yes | Project UUID; used as-is to build the R2 key (projectId/filename) — not cross-checked against any session or API key. See Security Considerations. |
filename | string | Yes | Generated filename (from R2 key) |
Query Parameters (Image Transformations)
Only apply to image files (image/* MIME types). Non-images ignore transform params.
| Parameter | Type | Description | Example Values |
|---|---|---|---|
width | number | Target width in pixels | 800, 1200 |
height | number | Target height in pixels | 600, 800 |
fit | string | Resize fit mode | contain, cover, crop, scale-down |
quality | number | Image quality (1-100) | 80, 90 |
format | string | Output format | webp, avif, auto |
Fit Modes:
- contain: Preserve aspect ratio, image fits within dimensions (may have whitespace)
- cover: Preserve aspect ratio, fills dimensions (may crop)
- crop: Crop to exact dimensions
- scale-down: Like
containbut never upscale
Request Examples
Original image:
https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg
Thumbnail (200x200 cover crop):
https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg?width=200&height=200&fit=cover
Note: This does not preserve the original
.jpgformat. Supplying any transform parameter without an explicitformatstill re-encodes the output — the default output format iswebp, not the source format. Pass&format=aviffor AVIF, or see Fallback below for when the original bytes are served unchanged.
Responsive image (800px wide, WebP):
https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg?width=800&format=webp&quality=85
Hero image (1920px, scale-down, AVIF):
https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg?width=1920&fit=scale-down&format=avif
Response (200 OK)
Headers (untransformed / fallback path — no transform params, or a non-image file):
Content-Type: image/jpeg
Cache-Control: public, max-age=31536000
ETag: "<r2-object-etag>"
Headers (successfully transformed image): the Cloudflare Images binding's own response headers are passed through, with Cache-Control overridden to public, max-age=31536000. ETag is not guaranteed on this path — the binding does not necessarily emit one, so don't rely on it for conditional requests against transformed URLs. Only the untransformed/fallback response above is guaranteed to carry the R2 object's ETag.
Body: Binary image data (original or transformed)
Cloudflare Image Resizing
If transform params (width, height, fit, quality, format) are present on an image request:
- The Worker reads the file's bytes from R2 and passes them to the Cloudflare Images binding (
env.IMAGES.input(...).transform(...).output(...)) — not by setting acf: { image }option on theResponseobject. The code's own comment calls out thatcf.imageon aResponseis a documented no-op; it only has an effect on afetch()subrequest, which this route doesn't make. - The binding transforms the image bytes directly and returns the result.
- Cloudflare's CDN caches the resulting response at edge locations (via the
Cache-Controlheader above). - Subsequent requests for the same transformed URL are served from cache.
Fallback: If the Images binding throws (e.g. unsupported input), the original, untransformed bytes are served instead (graceful degradation) — this response uses the plain R2 headers shown above, including ETag.
Caching Behavior
- Cache-Control:
public, max-age=31536000(1 year) - Immutable content: Generated filenames (UUIDs) never change
- Cache invalidation: Not needed (UUID ensures unique URLs)
- Cloudflare CDN: Caches at edge locations globally
Error Responses
404 Not Found - Media doesn't exist or tenant mismatch
{
"error": {
"code": "NOT_FOUND",
"message": "Media not found"
}
}
Note: No authentication required means anyone with the URL can access media. Isolation relies on the URL being unguessable (UUID filename), not on server-side validation of :projectId against the requester.
Security Considerations
- Tenant Isolation: R2 key is
projectId/filename— the URL's:projectIdsegment is used as-is to build the key, with no cross-check against an authenticated session - URL Enumeration: UUID filenames prevent guessing
- No Directory Listing: R2 bucket not publicly browsable
- Content-Type: Set correctly to prevent execution (e.g., SVG as
image/svg+xml)
Data Models
Media shapes by context (read this first)
Media is represented two different ways depending on where you get it. Mixing them up is a common migration snag:
| Context | Shape | Key fields | Has id? | Notes |
|---|---|---|---|---|
| Inline media resolved inside page/collection content | TentoMedia (underscore-prefixed) | _url, _filename, _mimeType, _width?, _height?, _altText? | No | Use client.media.transformUrl(_url, …) for transforms — there's no id. _altText is omitted when unset; SVGs omit _width/_height. An empty image field can come back as "" (so a field is TentoMedia | "" | undefined). |
GET /api/v1/media/:id (metadata) and admin list/get | Media (non-underscore) | id, filename, originalFilename, mimeType, size, width?, height?, altText?, folder?, url, createdAt, updatedAt | Yes | Render from the returned url (it points at the served bytes and carries any transform params); fetch it via client.media.getById(id). Note: /api/v1/media/:id returns JSON metadata, not image bytes — don't point an <img src> at it (or at getImageUrl(id)) directly. |
So: in content, code against TentoMedia (_url, no id); from the media API, code against the Media object (id, url). Don't expect _altText/_width/_height to always be present on inline media.
Media
Complete media object returned by API endpoints.
interface Media {
id: string // UUID
projectId: string // Project UUID
filename: string // Generated filename (UUID + ext)
originalFilename: string // User's original filename
mimeType: string // MIME type (e.g., "image/jpeg")
size: number // File size in bytes
r2Key: string // R2 storage key (tenant/filename)
altText?: string // Alt text for images (max 500 chars)
folder: string // Folder path (empty string = root)
metadata?: MediaMetadata // Additional metadata
createdBy: string // User UUID who uploaded
createdAt: string // ISO 8601 timestamp
updatedAt: string // ISO 8601 timestamp
deletedAt?: string | null // Soft delete timestamp
}
MediaMetadata
Optional metadata stored as JSON in D1.
interface MediaMetadata {
width?: number // Image width in pixels
height?: number // Image height in pixels
duration?: number // Video duration in seconds
[key: string]: unknown // Extensible for future metadata
}
Note: Current implementation does not extract image dimensions. Future enhancement to use Image Resizing format=json endpoint.
MediaUsage
Usage tracking response.
interface MediaUsage {
pages: PageRef[]
}
interface PageRef {
id: string // Page UUID
name: string // Page name
slug: string // Page slug
}
Error Handling
Error Response Format
All error responses follow this structure:
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message"
}
}
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid credentials |
FORBIDDEN | 403 | Insufficient permissions |
NOT_FOUND | 404 | Resource not found or wrong tenant |
HAS_REFERENCES | 409 | Cannot delete media — still referenced by pages/collection items (bypass with ?force=true) |
CONFLICT | 409 | A folder with the target name already exists (folder rename only — unrelated to media deletion) |
VALIDATION_ERROR | 400 | Invalid input (file type, size, params) |
INVALID_REQUEST | 400 | Malformed request (no file, invalid JSON) |
RATE_LIMITED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Server error |
Common Error Scenarios
Public media serving:
- Unknown project ID or filename → 404
NOT_FOUND - Transform params on a non-image file → the file is served unchanged (transforms apply to images only)
Upload validation, permission, and delete-conflict scenarios belong to the session-authenticated admin endpoints — see the Media Admin API for those flows and client-side error-handling examples.
Rate Limiting
Rate limits differ by media endpoint:
- Media metadata reads (
GET /api/v1/media,GET /api/v1/media/:id,/transform) — the standard public read limit, in-Worker per API key and counted per cache miss, in line with the other public read endpoints. Figures: Limits & Errors. - Admin media endpoints (
/api/v1/admin/media/*) — the admin API per-IP limit, enforced in the Worker; figure in Limits & Errors. - Raw media serving (
GET /api/v1/media/:projectId/:filename, the file bytes) — no per-key in-worker limit. It's covered only by the coarse per-IP Cloudflare WAF backstop, live since 2026-07-13; figures in Limits & Errors. Cloudflare's CDN cache further reduces how often these requests reach the origin for repeat/transformed URLs.
A 429 response uses the RATE_LIMITED code. See Limits & Errors for the full limit table, rate-limit headers, and 429 response shape.
Best Practices
Image Transformation Best Practices
1. Use Predefined Sizes
Define common image sizes in your app:
const IMAGE_SIZES = {
thumbnail: { width: 200, height: 200, fit: 'cover' },
small: { width: 400, fit: 'scale-down' },
medium: { width: 800, fit: 'scale-down' },
large: { width: 1200, fit: 'scale-down' },
hero: { width: 1920, fit: 'scale-down' },
}
function getImageUrl(media, size = 'medium') {
const params = new URLSearchParams(IMAGE_SIZES[size])
return `/api/v1/media/${media.r2Key}?${params}`
}
2. Responsive Images with srcset
Generate multiple sizes for responsive images:
<img
src="/api/v1/media/tenant/image.jpg?width=800"
srcset="
/api/v1/media/tenant/image.jpg?width=320 320w,
/api/v1/media/tenant/image.jpg?width=640 640w,
/api/v1/media/tenant/image.jpg?width=960 960w,
/api/v1/media/tenant/image.jpg?width=1280 1280w
"
sizes="(max-width: 640px) 100vw, 640px"
alt="Hero image"
/>
3. Use WebP/AVIF with Fallback
Prefer modern formats with fallback:
<picture>
<source
srcset="/api/v1/media/tenant/image.jpg?width=800&format=avif"
type="image/avif"
/>
<source
srcset="/api/v1/media/tenant/image.jpg?width=800&format=webp"
type="image/webp"
/>
<img src="/api/v1/media/tenant/image.jpg?width=800" alt="Hero" />
</picture>
Performance Best Practices
Lazy Load Thumbnails
Use native lazy loading for media grids:
<img
src="/api/v1/media/tenant/image.jpg?width=200&height=200&fit=cover"
loading="lazy"
alt="Thumbnail"
/>
List/search, folder-caching, and pagination tips for the admin media endpoints live in the Media Admin API.
Summary
The Media API provides a comprehensive set of endpoints for managing media assets in a multi-tenant CMS:
- Full media lifecycle covering upload, organise, serve, and delete (upload/manage endpoints are documented in the Media Admin API)
- Strong tenant isolation enforced at storage (R2) and data (D1) layers
- Usage tracking prevents accidental deletion of referenced media
- Image transformations via Cloudflare Image Resizing for dynamic resizing
- Soft delete pattern for recovery
- Bulk operations with partial success handling
- Public serving with 1-year cache for optimal performance
Key Considerations:
- Validate files client-side and server-side (defense in depth)
- Check usage before deletion (409
HAS_REFERENCESif still referenced by content) - Use image transforms for responsive, optimized images
- Organize media with folders (2-3 levels max)
- Respect rate limits (especially bulk upload)
- SVG uploads are sanitized server-side to prevent XSS
For related guidance, see:

