The image URL
Every image is a URL. There is no SDK call to make one, no token to attach, and nothing to sign unless you ask for it:
https://img.truo.cloud/i/<pid>/uploads/photo.jpg?w=800&f=auto<pid> is your tenant’s public id, shown in the console under Images →
Endpoint. It is not a secret — it appears in every image URL on your site.
Two modes share the path
Section titled “Two modes share the path”https://img.truo.cloud/i/acme/uploads/photo.jpg your own originhttps://img.truo.cloud/i/acme/fetch/https%3A%2F%2F… somebody else'sThe first maps the path onto the origin you configured. The second proxies an absolute URL, which has to be percent-encoded whole so its slashes do not split into path segments.
If you genuinely have a fetch/ directory, it still works: the proxy mode only
triggers when what follows fetch/ starts with http:// or https://.
Both go through the same allowlist
Section titled “Both go through the same allowlist”Configuring an origin is not an authorisation. Every request — mapped or proxied — is checked against the origin patterns on your tenant, so removing a pattern actually stops the traffic.
Parameters
Section titled “Parameters”Names from imgix, ImageKit and Cloudinary are accepted and translated, so you can usually paste a URL from another provider and change the host.
| Parameter | Also accepted as |
|---|---|
a |
align, focus, gravity |
af |
adaptive |
bg |
background |
blur |
— |
bri |
brightness |
cbg |
— |
con |
contrast |
crop |
— |
dpr |
— |
f |
fm, format, output |
filename |
— |
filt |
filter |
fit |
t |
flip |
— |
flop |
— |
gam |
gamma |
h |
height |
hue |
— |
il |
interlace, progressive |
l |
level |
ll |
lossless |
mask |
— |
mbg |
— |
mod |
— |
mtrim |
— |
n |
frames |
page |
— |
precrop |
— |
q |
quality |
rbg |
— |
ro |
rot, rotate |
sat |
saturation |
sharp |
sharpen |
tbg |
— |
tint |
— |
trim |
— |
w |
width |
we |
withoutenlargement |
An unknown parameter is dropped in silence — real HTML carries ?ver=6.4 glued
to image URLs, and answering 400 would break pages over decoration.
An invalid value is not: ?w=abc answers 400, because the alternative is
returning the full-size image and billing you for it while you believe you
asked for a thumbnail.
A value out of range is clamped, and the response says so with
X-Img-Clamped. That is deliberate: a srcset overflowing on a huge viewport
should not break the page.
fit stretches, if you are coming from imgix
Section titled “fit stretches, if you are coming from imgix”fit=fill follows sharp’s semantics and stretches the image. imgix
letterboxes. Use fit=contain for padding — it is the one difference that
catches people exactly once.
Format negotiation
Section titled “Format negotiation”f=auto picks a format from the browser’s Accept header and answers
Vary: Accept:
Accept: image/avif,image/webp,*/* → image/webpAccept: image/webp,*/* → image/webpAccept: image/avif,*/* → image/avifAccept: */* → the source formatauto prefers WebP over AVIF, which surprises people, so here is the
measurement behind it. Against the transformation engine under its production
quota, a burst of 24 transforms of a real product image took 9.72s and 990 MB
of peak memory in AVIF, against 1.21s and 267 MB in WebP — eight times the
wall clock. Isolated at w=1200: AVIF 2.20s for 189 KB, WebP 0.24s for 138 KB.
On four of five real catalogue images WebP was also smaller at the same q.
AVIF earns its cost on large photographic content at low quality. Product shots,
logos and UI assets are where it loses, and that is most of what a CDN serves.
The size comparison is at equal q and the two codecs’ quality scales are not
perceptually equivalent — but the CPU difference does not depend on that.
If you want AVIF, ask for it: f=avif is unchanged, and auto still returns it
for a client that accepts AVIF but not WebP.
*/* alone does not count as support. Bots, proxies and curl send it, and
serving them avif is how half of an integration breaks silently.
Caching
Section titled “Caching”Responses carry your tenant’s TTL, an ETag, and stale-while-revalidate:
cache-control: public, max-age=86400, s-maxage=86400, stale-while-revalidate=86400etag: W/"6bab8bd88028581d72162fab1bd2417a"immutable is opt-in per tenant and off by default. A year of immutable on a
URL whose file can change is not revocable from a browser — it is the expensive
mistake everybody makes once.
Errors are always no-store. A cached 403 leaves a site broken long after the
allowlist is fixed, and the diagnosis is miserable.
Signed URLs
Section titled “Signed URLs”Signing is optional, per tenant. Turn on require_signature and every
request needs an s parameter:
https://img.truo.cloud/i/acme/uploads/private.jpg?w=800&s=lUFvTTMh1YVYW9i6vKYy…s = base64url(HMAC-SHA256(secret, "v2\n" + pid + "\n" + pathname + "\n" + canonicalQuery))Three things about that payload matter:
- the pathname is in it. In this contract the path is the source image, so signing only the query would leave the signature decorative — anyone could swap the file and keep it valid.
expis optional. Without it the URL does not expire, which is what static HTML, a CMS and a CDN all need. It travels as a normal parameter, so the canonical query already covers it and it cannot be altered.- the canonical query is sorted by name then value, excludes
s, and percent-encodes both sides — including the comma, which the wire form keeps literal.
In practice you do not implement this. @truocloud/img/sign does it:
import { signUrl } from "@truocloud/img/sign";
const url = await signUrl(img.url("uploads/private.jpg", { width: 800 }), { secret: process.env.TRUO_IMG_SECRET, // server-side only});When a tenant runs out of quota the service answers 302 to the origin rather than an error. Images keep loading, unoptimised.
That matters for your Content Security Policy: img-src needs
img.truo.cloud and your origin, or the fallback is the thing that breaks.
The URL vectors
Section titled “The URL vectors”Every rule on this page is pinned by a fixture of URL vectors, generated by the service from its own parser and signer:
https://img.truo.cloud/fixtures/urls.jsonIt ships inside @truocloud/img as well, at
@truocloud/img/fixtures/urls.json. It is what keeps the TypeScript builder,
the WordPress plugin’s PHP builder and the service itself emitting
byte-identical URLs — down to whether mi foto (1).jpg escapes its parentheses.
If you build these URLs yourself, test against it.