Image Transformations
Freedam exposes a Cloudflare-style image transformation proxy that lets you build a derivative of any image asset — resized, re-encoded, optionally watermarked — by composing a URL. The URLs are cookieless, ETag-aware, and safe to embed in HTML, RSS feeds, third-party CMSes, or any caching CDN sitting in front of your Freedam instance.
Unlike the /api/v1/... endpoints, transform URLs require no Sanctum token and are not counted against your API rate limit. They have their own per-URL throttle (see Rate limiting below) and are served straight from the tenant host.
There are three transform paths, picked by what kind of access you need:
| Path | Auth | Cache | Watermarking | Who builds the URL |
|---|---|---|---|---|
/i/{options}/{gaid} |
None | Public | Never | You (the integrator) |
/is/{options}/{gaid} |
Signed + uid |
Private | Per-user download policy | Freedam (returned via the API) |
/ip/{options}/{gaid} |
Signed + uid |
Private | Per-user preview policy | Freedam (returned via the API) |
Heads up. Only
/i/is designed for third-party URL construction./is/and/ip/carry a Laravel signed-URL signature that requires the tenant's secret app key, so external integrators cannot build them — but you will receive them in API payloads and should be ready to render them.
The public endpoint — /i/
GET /i/{options}/{assetGaid}
assetGaid— the asset's Global Asset ID (the same identifier used by every/api/v1/assets/{gaid}endpoint).options— a comma-separated list ofkey=valuepairs (described below), or the literal stringoriginalto skip any resizing or re-encoding.
A bare GET is all it takes:
curl -o hero.webp \
"https://acme.freedam.io/i/w=1280,q=80,f=webp/A_01HXYZAB12CDEF"
The response is the binary image, served with:
Content-Type—image/jpeg,image/png,image/webp, orimage/avifdepending on the resolved format.Cache-Control—public, max-age=31536000, s-maxage=31536000, immutable.ETag— strong validator that includes the asset's mutable state. Replays withIf-None-Matchreturn304 Not Modified.Vary: Accept— so format auto-negotiation stays cacheable per client.
Because the response is immutable, CDNs and browsers will cache it for up to a year. If an asset is replaced or re-processed inside Freedam, its ETag rotates and downstream caches refresh on the next conditional request.
Options syntax
| Key | Meaning | Range / values | Default |
|---|---|---|---|
w |
Target width in pixels | 1–5120 |
source width (after fit) |
h |
Target height in pixels | 1–5120 |
source height (after fit) |
q |
Encoder quality | 1–100 |
85 |
f |
Output format | jpeg | jpg | png | webp | avif |
auto-negotiated from Accept |
fit |
Resize strategy | cover | contain | fill |
contain |
dpr |
Device-pixel-ratio multiplier | 1–3 |
1 |
Order does not matter; keys are case-sensitive and short. Examples:
/i/w=800/A_01HXYZ # width-only, format auto-negotiated, contain
/i/w=400,h=400,fit=cover/A_01HXYZ # square crop, 400×400
/i/w=1600,q=70,f=avif/A_01HXYZ # forced AVIF at lower quality
/i/w=800,dpr=2/A_01HXYZ # request 1600px image, advertised as 2× of 800 CSS px
/i/original/A_01HXYZ # source, no resize/re-encode (format still negotiated)
fit semantics
contain(default) — preserve aspect ratio, no crop. The result fits insidew × hand the smaller axis is rounded down to maintain ratio.cover— preserve aspect ratio, crop the overflow so the output exactly fillsw × h.fill— stretch to exactlyw × h, ignoring aspect ratio.
If only one of w or h is given, the omitted axis is computed from the source aspect ratio.
dpr and the source tier picker
Freedam pre-renders preview tiers at fixed sizes (128, 256, 512, 1024, 2048, 4096, 5120 px on the long edge). The transform endpoint picks the smallest tier ≥ your requested long edge as its source, then resizes down. dpr multiplies the requested edge before picking the tier, so w=800,dpr=2 re-encodes from the 2048 tier (not 1024). This gives crisp Retina output without forcing every request to read the heaviest source.
Format auto-negotiation
If you omit f=, the server inspects the request's Accept header in this order:
image/avif→ encodes AVIF (best compression).image/webp→ encodes WebP.- otherwise → encodes JPEG.
If you need a deterministic format (e.g. when emailing a snippet that may render in a client without Accept), set f= explicitly.
Errors
| Status | Meaning |
|---|---|
400 |
One of the options is out of range or malformed. |
403 |
The request is not in a valid tenant context. |
404 |
The asset does not exist, or no preview tier is available yet. |
429 |
Per-URL transform rate limit hit (see below). |
500 |
The image encoder failed; the request is logged for triage. |
Error responses are plain HTTP (no JSON envelope) so they can be rendered by <img> tags without surfacing leaking detail.
Rate limiting
Transform URLs use their own bucket: 10 requests / minute per (ip, asset_gaid, options) triple. This is intentionally generous for cache misses but tight enough to prevent enumeration. Once a CDN or browser caches the response, subsequent requests bypass the origin entirely and never count against the budget.
The /api/v1 token budget documented in Rate Limiting is independent of this throttle.
Tenant context
Transform URLs must be hit on the tenant host that owns the asset (e.g. https://acme.freedam.io/i/..., not the central / marketing domain). Cross-tenant URLs return 403. The host header is the only thing identifying the tenant — there is no token to fall back on.
Signed transforms — /is/ and /ip/
The two signed variants are documented here for completeness, but you will not construct them yourself. They appear in API responses (asset detail, gallery cards, share payloads) when Freedam needs to enforce per-user policy on a transformed image.
/is/{options}/{gaid} — download-class signed transform
GET /is/w=1600,q=85/A_01HXYZ?signature=...&uid=42&wm=7
- Signature is a Laravel signed-URL signature; tampering invalidates it.
uididentifies the user the URL was minted for; their download policy and resolution access are re-checked on every request.wm(optional) pins a specific watermark id. If the user's policy mandates a watermark, the request must carry the matching id or it is rejected.- Same options grammar as
/i/, same throttle bucket. - Responses are
Cache-Control: private, max-age=3600so shared caches won't reuse them across users.
/ip/{options}/{gaid} — preview-class signed transform
Identical to /is/ but enforces the preview ACL (which is laxer than download in many tenancies — e.g. trash-bin viewers can still preview a deleted asset for audit reasons but cannot download it). /ip/ responses include an X-Transform-Cache: hit|miss header indicating whether the persistent watermarked-bytes cache served the request.
If a watermark policy mismatch is detected (e.g. the user's policy was changed after the URL was minted), the request returns 403 with no body. Refresh the asset detail payload to mint a new URL.
Other asset delivery URLs
Beyond the transform endpoints, the API may hand you a handful of other signed URLs. You don't construct these; you just follow them.
| URL shape | Purpose | Notes |
|---|---|---|
/assets/preview/{gaid}/{resolution} |
Serve a non-watermarked preview at a fixed tier (w_256, w_2048, …). |
Signed URL, short-lived. Use for <img> src when a pre-rendered tier is enough and no on-the-fly resize needed. |
/assets/download/{gaid}/{resolution} |
Force a download with Content-Disposition: attachment. |
Signed. resolution=original returns the source file. |
/assets/download-prepare/{gaid}/{resolution} |
Public-facing landing page that auto-triggers the download via JS. | Use this in email links so users see a "Your download has started" page rather than a blank tab. |
/assets/stream/{gaid}/master.m3u8 |
HLS master playlist for video assets. | Signed. Variant playlists and .m4s segments are independently signed with a 6-hour TTL. |
/assets/stream/{gaid}/subtitles/{language}.m3u8 |
HLS subtitle playlist for a given language tag. | Signed. Language tag is a 1–16 char [A-Za-z0-9_-]+. |
/embed/{shareToken}/{gaid} |
Public video embed for <iframe>. |
Tokens are minted via /api/v1/assets/{gaid}/embed. |
Signed URLs returned in API responses include the signature directly — pass them through to your <img>, <a>, or <video> tag unchanged. Do not strip query parameters; doing so will invalidate the signature.
Building transform URLs in your own backend
If you are proxying Freedam assets through your own service, the public /i/ URL is just string concatenation:
// TypeScript
function freedamImageUrl(
gaid: string,
opts: {
width?: number;
height?: number;
quality?: number;
format?: 'jpeg' | 'png' | 'webp' | 'avif';
fit?: 'cover' | 'contain' | 'fill';
dpr?: 1 | 2 | 3;
},
): string {
const parts: string[] = [];
if (opts.width) parts.push(`w=${opts.width}`);
if (opts.height) parts.push(`h=${opts.height}`);
if (opts.quality) parts.push(`q=${opts.quality}`);
if (opts.format) parts.push(`f=${opts.format}`);
if (opts.fit) parts.push(`fit=${opts.fit}`);
if (opts.dpr) parts.push(`dpr=${opts.dpr}`);
const options = parts.length ? parts.join(',') : 'original';
return `https://acme.freedam.io/i/${options}/${gaid}`;
}
# Python
def freedam_image_url(gaid: str, **opts) -> str:
short = {"width": "w", "height": "h", "quality": "q",
"format": "f", "fit": "fit", "dpr": "dpr"}
parts = [f"{short[k]}={v}" for k, v in opts.items() if v is not None]
options = ",".join(parts) if parts else "original"
return f"https://acme.freedam.io/i/{options}/{gaid}"
There is no need to URL-encode the options segment — the grammar only uses ASCII identifiers, digits, and the , / = separators that Laravel routes through verbatim.
Worked examples
Responsive <img> srcset. Let the browser pick the right pixel density, with format auto-negotiation:
<img
src="https://acme.freedam.io/i/w=800/A_01HXYZ"
srcset="
https://acme.freedam.io/i/w=400/A_01HXYZ 400w,
https://acme.freedam.io/i/w=800/A_01HXYZ 800w,
https://acme.freedam.io/i/w=1600/A_01HXYZ 1600w
"
sizes="(max-width: 600px) 100vw, 800px"
alt="…"
/>
Square thumbnail for a card grid.
https://acme.freedam.io/i/w=300,h=300,fit=cover,q=80/A_01HXYZ
Forced AVIF poster. When you need deterministic AVIF (e.g. for OpenGraph tags consumed by crawlers that do not advertise Accept: image/avif):
https://acme.freedam.io/i/w=1200,h=630,fit=cover,f=avif/A_01HXYZ
Skip resizing, just re-encode. Use original to request the largest preview tier in the negotiated format:
https://acme.freedam.io/i/original/A_01HXYZ